diff --git a/README.md b/README.md index 05ad38c..5121583 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Beyond a name and appearance, characters have traits, professions, injuries, and - **Character profiles** — create and switch between characters, with race, traits, descriptions, and website-connected creation. - **Roleplay conversation** — use local speech, whispers, shouts, actions, and out-of-character channels, with speech bubbles and channel preferences. - **Identity and disguise** — show character identities in social interactions and support masks and alternate personas. +- **Character focus** — a shared, regenerating per-character resource used by Research and Magic. - **Progression and rolls** — bring professions, attributes, and dice rolls into character gameplay. - **Injuries and recovery** — represent injuries and prosthetics, with related treatment and progression systems. - **Consequences and investigation** — support lethal or nonlethal PvP, graves, and discoverable clues left in the world. diff --git a/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java b/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java index 7e0516d..a877a55 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java +++ b/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java @@ -109,6 +109,7 @@ public class RPCharacters extends JavaPlugin{ public static RPCharacters plugin; + private net.tfminecraft.rpcharacters.focus.FocusModule focusModule; private final CommandManager commandManager = new CommandManager(); private static final PlayerManager playerManager = new PlayerManager(); @@ -231,6 +232,11 @@ public void onEnable() { GraveManager.get().loadAll(); MailRecipientDirectory.scanFromDisk(); loadPlayers(); + focusModule = new net.tfminecraft.rpcharacters.focus.FocusModule(this); + focusModule.start(); + var focusCommand = new net.tfminecraft.rpcharacters.focus.FocusCommand(this); + getCommand("focus").setExecutor(focusCommand); + getCommand("focus").setTabCompleter(focusCommand); startManagers(); getCommand(commandManager.cmd1).setExecutor(commandManager); getCommand("rpcharacter").setTabCompleter(new CommandTabCompleter()); @@ -253,6 +259,7 @@ public void onEnable() { } @Override public void onDisable() { + if (focusModule != null) focusModule.shutdown(); ChatRecipientResolverRegistry.unregister( net.tfminecraft.rpcharacters.party.PartyManager.PARTY_RESOLVER_ID); net.tfminecraft.rpcharacters.ingest.CharacterIngestService.stopPeriodicPull(); @@ -490,11 +497,21 @@ public void createConfigs() { } public void reload() { + reloadWithFocusStatus(); + } + + private boolean reloadWithFocusStatus() { loadConfigs(); + boolean focusReloaded = reloadFocusConfig(); + if (!focusReloaded) { + getLogger().warning("Focus did not reload; see the preceding focus error. " + + "Continuing the other RPCharacters reload steps."); + } LastSolidTracker.get().start(); ProfessionCommandHandler.reapplyActiveCharacterPerms(); // Catalog + pending pull already run inside loadConfigs(); also refresh website sheets. net.tfminecraft.rpcharacters.ingest.RosterSyncService.pushAllOnlineAsync(); + return focusReloaded; } public void reloadConfigs(CommandSender sender) { @@ -504,10 +521,14 @@ public void reloadConfigs(CommandSender sender) { RPTexts.sendPrefixed(sender, RPTexts.WARN + "Reloading configs..."); } try { - reload(); - getLogger().info("Config reload complete (catalog + online roster sync kicked)."); + boolean focusReloaded = reloadWithFocusStatus(); + getLogger().info(focusReloaded + ? "Config reload complete (catalog + online roster sync kicked)." + : "Other configs reloaded (catalog + online roster sync kicked); focus did not reload."); if (sender != null) { - RPTexts.sendPrefixed(sender, RPTexts.WARN + "Reloading complete!"); + RPTexts.sendPrefixed(sender, RPTexts.WARN + (focusReloaded + ? "Reloading complete!" + : "Other configs reloaded; focus did not reload. Check console for the focus error.")); } } catch (Exception e) { getLogger().severe("Config reload failed: " + e.getMessage()); @@ -518,6 +539,15 @@ public void reloadConfigs(CommandSender sender) { } } + /** Character focus; null when startup fails. */ + public static net.tfminecraft.rpcharacters.focus.FocusService getFocusService() { + return plugin == null || plugin.focusModule == null ? null : plugin.focusModule.getService(); + } + + public boolean reloadFocusConfig() { + return focusModule != null && focusModule.reloadConfig(); + } + public static PlayerManager getPlayerManager() { return playerManager; } diff --git a/src/main/java/net/tfminecraft/rpcharacters/focus/FocusAttributes.java b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusAttributes.java new file mode 100644 index 0000000..9dc6edc --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusAttributes.java @@ -0,0 +1,24 @@ +package net.tfminecraft.rpcharacters.focus; + +import org.bukkit.entity.Player; + +import net.Indyuce.mmocore.api.player.PlayerData; + +final class FocusAttributes { + + private FocusAttributes() {} + + static double getTotal(Player player, String mmocoreId) { + if (player == null || mmocoreId == null || mmocoreId.isBlank()) { + return 0; + } + if (player.getServer().getPluginManager().getPlugin("MMOCore") == null) { + return 0; + } + try { + return PlayerData.get(player).getAttributes().getInstance(mmocoreId).getTotal(); + } catch (Exception ex) { + return 0; + } + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/focus/FocusCommand.java b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusCommand.java new file mode 100644 index 0000000..079ec31 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusCommand.java @@ -0,0 +1,69 @@ +package net.tfminecraft.rpcharacters.focus; + +import java.util.List; +import java.util.Locale; + +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; + +import net.tfminecraft.rpcharacters.RPCharacters; + +public final class FocusCommand implements CommandExecutor, TabCompleter { + public static final String PERMISSION = "rpchar.focus.admin"; + private final RPCharacters plugin; + + public FocusCommand(RPCharacters plugin) { + this.plugin = plugin; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!sender.hasPermission(PERMISSION)) { + sender.sendMessage("You do not have permission to use this command."); + return true; + } + if (args.length == 1 && args[0].equalsIgnoreCase("reload")) { + boolean loaded = plugin.reloadFocusConfig(); + sender.sendMessage(loaded ? "Focus configuration reloaded." + : "Focus configuration did not reload. Check console for the configuration error."); + return true; + } + if (args.length == 2 && args[0].equalsIgnoreCase("restore")) { + Player target = Bukkit.getPlayerExact(args[1]); + if (target == null) { + sender.sendMessage("Player not found: " + args[1]); + return true; + } + FocusService focus = RPCharacters.getFocusService(); + if (focus == null || !focus.restore(target)) { + sender.sendMessage("Could not restore focus for " + target.getName() + + ". Check their active character and the focus service in console."); + return true; + } + sender.sendMessage("Restored focus for " + target.getName() + " (" + + focus.getPoints(target) + "/" + focus.getMax() + ")."); + return true; + } + sender.sendMessage("Usage: /focus restore | /focus reload"); + return true; + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + if (!sender.hasPermission(PERMISSION)) return List.of(); + if (args.length == 1) { + return List.of("restore", "reload").stream() + .filter(value -> value.startsWith(args[0].toLowerCase(Locale.ROOT))).toList(); + } + if (args.length == 2 && args[0].equalsIgnoreCase("restore")) { + String prefix = args[1].toLowerCase(Locale.ROOT); + return Bukkit.getOnlinePlayers().stream().map(Player::getName) + .filter(name -> name.toLowerCase(Locale.ROOT).startsWith(prefix)).sorted().toList(); + } + return List.of(); + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/focus/FocusConfig.java b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusConfig.java new file mode 100644 index 0000000..a32d974 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusConfig.java @@ -0,0 +1,25 @@ +package net.tfminecraft.rpcharacters.focus; + +import java.util.ArrayList; +import java.util.List; + +public final class FocusConfig { + + public static int max = 150; + public static int basePerHour = 10; + public static long regenIntervalTicks = 72000L; + public static boolean offlineRegen = true; + public static final List regenBonuses = new ArrayList<>(); + + private FocusConfig() {} + + public static final class RegenBonus { + public final String mmocoreId; + public final double extraPerHourPerPoint; + + public RegenBonus(String mmocoreId, double extraPerHourPerPoint) { + this.mmocoreId = mmocoreId; + this.extraPerHourPerPoint = extraPerHourPerPoint; + } + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/focus/FocusConfigLoader.java b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusConfigLoader.java new file mode 100644 index 0000000..be7a8a9 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusConfigLoader.java @@ -0,0 +1,46 @@ +package net.tfminecraft.rpcharacters.focus; + +import java.io.File; +import java.io.IOException; +import java.util.Map; + +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; + +import net.tfminecraft.rpcharacters.RPCharacters; + +public final class FocusConfigLoader { + + private FocusConfigLoader() {} + + public static boolean load(File file) { + return load(file, RPCharacters.plugin.getLogger()); + } + + static boolean load(File file, java.util.logging.Logger logger) { + FileConfiguration config = new YamlConfiguration(); + try { + config.load(file); + } catch (IOException | InvalidConfigurationException ex) { + logger.severe("[RPCharacters] Failed to load focus.yml: " + ex.getMessage()); + return false; + } + FocusConfig.max = Math.max(1, config.getInt("max", FocusConfig.max)); + FocusConfig.basePerHour = config.getInt("base_per_hour", FocusConfig.basePerHour); + FocusConfig.regenIntervalTicks = Math.max(1L, config.getLong("regen_interval_ticks", FocusConfig.regenIntervalTicks)); + FocusConfig.offlineRegen = config.getBoolean("offline_regen", FocusConfig.offlineRegen); + FocusConfig.regenBonuses.clear(); + for (Map map : config.getMapList("regen_bonuses")) { + String id = map.get("mmocore_id") != null ? String.valueOf(map.get("mmocore_id")) : ""; + double extra = 0; + if (map.get("extra_per_hour_per_point") instanceof Number number) { + extra = number.doubleValue(); + } + if (!id.isBlank()) { + FocusConfig.regenBonuses.add(new FocusConfig.RegenBonus(id, extra)); + } + } + return true; + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/focus/FocusData.java b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusData.java new file mode 100644 index 0000000..923fd44 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusData.java @@ -0,0 +1,95 @@ +package net.tfminecraft.rpcharacters.focus; + +public final class FocusData { + + private String characterId; + private String ownerUuid; + private int points; + private long lastRegenMs; + + public FocusData() {} + + public static FocusData createNew(String characterId, String ownerUuid) { + FocusData data = new FocusData(); + data.characterId = characterId; + data.ownerUuid = ownerUuid; + data.points = FocusConfig.max; + data.lastRegenMs = System.currentTimeMillis(); + return data; + } + + public String getCharacterId() { + return characterId; + } + + public void setCharacterId(String characterId) { + this.characterId = characterId; + } + + public String getOwnerUuid() { + return ownerUuid; + } + + public void setOwnerUuid(String ownerUuid) { + this.ownerUuid = ownerUuid; + } + + public int getPoints() { + return points; + } + + public void setPoints(int points) { + this.points = clamp(points); + } + + public long getLastRegenMs() { + return lastRegenMs; + } + + public void setLastRegenMs(long lastRegenMs) { + this.lastRegenMs = lastRegenMs; + } + + public int applyRegenForElapsed(double hourlyRate, long intervalMs, long nowMs) { + if (intervalMs <= 0 || hourlyRate <= 0) { + return 0; + } + if (points >= FocusConfig.max) { + lastRegenMs = nowMs; + return 0; + } + long elapsed = nowMs - lastRegenMs; + if (elapsed < intervalMs) { + return 0; + } + long intervals = elapsed / intervalMs; + double msPerHour = 3_600_000.0; + int perInterval = Math.max(1, (int) Math.round(hourlyRate * intervalMs / msPerHour)); + int newPoints = Math.min(points + (int) intervals * perInterval, FocusConfig.max); + int added = newPoints - points; + points = newPoints; + lastRegenMs += intervals * intervalMs; + return added; + } + + public boolean trySpend(int amount) { + if (amount <= 0) { + return true; + } + if (points < amount) { + return false; + } + points -= amount; + return true; + } + + public void grant(int amount) { + if (amount > 0) { + points = clamp(points + amount); + } + } + + private static int clamp(int value) { + return Math.min(Math.max(0, value), FocusConfig.max); + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/focus/FocusListener.java b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusListener.java new file mode 100644 index 0000000..1de6b62 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusListener.java @@ -0,0 +1,34 @@ +package net.tfminecraft.rpcharacters.focus; + +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerQuitEvent; + +import net.tfminecraft.rpcharacters.lifecycle.CharacterActivatedEvent; + +public final class FocusListener implements Listener { + + private final FocusService service; + + public FocusListener(FocusService service) { + this.service = service; + } + + @EventHandler + public void onCharacterActivated(CharacterActivatedEvent event) { + Player owner = event.getOwner(); + if (owner == null || event.getCharacter() == null) { + return; + } + if (event.getPrevious() != null) { + service.savePrevious(owner, event.getPrevious()); + } + service.activate(owner, event.getCharacter()); + } + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + service.deactivate(event.getPlayer()); + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/focus/FocusModule.java b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusModule.java new file mode 100644 index 0000000..4ec35da --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusModule.java @@ -0,0 +1,55 @@ +package net.tfminecraft.rpcharacters.focus; + +import java.io.File; + +import org.bukkit.event.HandlerList; +import org.bukkit.plugin.java.JavaPlugin; + +/** Owns focus startup, configuration, listeners and shutdown inside RPCharacters. */ +public final class FocusModule { + private final JavaPlugin plugin; + private FocusService service; + private FocusListener listener; + + public FocusModule(JavaPlugin plugin) { + this.plugin = plugin; + } + + public boolean start() { + if (service != null) return true; + try { + File config = new File(plugin.getDataFolder(), "focus.yml"); + if (!config.exists()) plugin.saveResource("focus.yml", false); + if (!FocusConfigLoader.load(config, plugin.getLogger())) return false; + var store = new FocusStore(new File(plugin.getDataFolder(), "data/focus")); + service = new FocusService(plugin, store); + listener = new FocusListener(service); + plugin.getServer().getPluginManager().registerEvents(listener, plugin); + service.start(); + return true; + } catch (RuntimeException ex) { + plugin.getLogger().severe("Focus startup stopped; existing data was preserved. Repair the reported " + + "file issue and reload RPCharacters: " + ex.getMessage()); + shutdown(); + return false; + } + } + + public boolean reloadConfig() { + if (service == null) return start(); + if (!FocusConfigLoader.load(new File(plugin.getDataFolder(), "focus.yml"), plugin.getLogger())) return false; + service.restartRegen(); + return true; + } + + public FocusService getService() { + return service; + } + + public void shutdown() { + if (listener != null) HandlerList.unregisterAll(listener); + if (service != null) service.shutdown(); + listener = null; + service = null; + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/focus/FocusService.java b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusService.java new file mode 100644 index 0000000..f75b08c --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusService.java @@ -0,0 +1,200 @@ +package net.tfminecraft.rpcharacters.focus; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitRunnable; +import org.bukkit.scheduler.BukkitTask; +import org.bukkit.plugin.java.JavaPlugin; + +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.rpcharacters.RPCharacters; + +public final class FocusService { + + private final JavaPlugin plugin; + private final FocusStore store; + private final Map loaded = new ConcurrentHashMap<>(); + private BukkitTask regenTask; + + public FocusService(FocusStore store) { + this(RPCharacters.plugin, store); + } + + public FocusService(JavaPlugin plugin, FocusStore store) { + this.plugin = plugin; + this.store = store; + } + + public void restartRegen() { + stopRegen(); + long interval = Math.max(1L, FocusConfig.regenIntervalTicks); + regenTask = new BukkitRunnable() { + @Override + public void run() { + tickRegen(); + } + }.runTaskTimer(plugin, interval, interval); + } + + public void start() { + restartRegen(); + for (Player player : Bukkit.getOnlinePlayers()) { + RPCharacter character = RPCharacters.getActiveCharacter(player); + if (character != null) { + activate(player, character); + } + } + } + + public void shutdown() { + stopRegen(); + saveAllOnline(); + loaded.clear(); + } + + public int getPoints(Player player) { + FocusData data = dataFor(player); + return data != null ? data.getPoints() : 0; + } + + public boolean trySpend(Player player, int amount) { + FocusData data = dataFor(player); + return data != null && data.trySpend(amount); + } + + public void grant(Player player, int amount) { + FocusData data = dataFor(player); + if (data != null) { + data.grant(amount); + } + } + + public int getMax() { + return FocusConfig.max; + } + + public boolean restore(Player player) { + FocusData data = dataFor(player); + if (data == null) { + return false; + } + data.setPoints(FocusConfig.max); + return saveSafely(data); + } + + public void activate(Player player, RPCharacter character) { + if (player == null || character == null || character.getId() == null || character.getId().isBlank()) { + return; + } + String characterId = character.getId(); + String owner = player.getUniqueId().toString(); + loaded.remove(player.getUniqueId()); + try { + FocusData data = store.load(characterId); + if (data == null) { + data = FocusData.createNew(characterId, owner); + store.save(data); + } else { + data.setOwnerUuid(owner); + } + applyOfflineRegen(player, data); + loaded.put(player.getUniqueId(), data); + } catch (RuntimeException ex) { + plugin.getLogger().severe("Focus unavailable for character " + characterId + ": " + ex.getMessage()); + } + } + + public void savePrevious(Player player, RPCharacter previous) { + if (player == null || previous == null) { + return; + } + FocusData data = loaded.get(player.getUniqueId()); + if (data == null) { + return; + } + if (!java.util.Objects.equals(previous.getId(), data.getCharacterId())) return; + saveSafely(data); + } + + public void deactivate(Player player) { + if (player == null) { + return; + } + FocusData data = loaded.remove(player.getUniqueId()); + if (data != null) { + saveSafely(data); + } + } + + public void saveAllOnline() { + for (Player player : Bukkit.getOnlinePlayers()) { + FocusData data = dataFor(player); + if (data != null) { + saveSafely(data); + } + } + } + + private boolean saveSafely(FocusData data) { + try { + store.save(data); + return true; + } catch (RuntimeException ex) { + plugin.getLogger().severe(ex.getMessage()); + return false; + } + } + + private FocusData dataFor(Player player) { + if (player == null) return null; + FocusData data = loaded.get(player.getUniqueId()); + if (data == null) return null; + RPCharacter active = RPCharacters.getActiveCharacter(player); + if (active == null || !java.util.Objects.equals(active.getId(), data.getCharacterId())) { + deactivate(player); + return null; + } + return data; + } + + private void applyOfflineRegen(Player player, FocusData data) { + long now = System.currentTimeMillis(); + long intervalMs = Math.max(1L, FocusConfig.regenIntervalTicks) * 50L; + if (FocusConfig.offlineRegen) { + data.applyRegenForElapsed(hourlyRate(player), intervalMs, now); + } else { + data.setLastRegenMs(now); + } + } + + private void tickRegen() { + long now = System.currentTimeMillis(); + long intervalMs = Math.max(1L, FocusConfig.regenIntervalTicks) * 50L; + for (Player player : Bukkit.getOnlinePlayers()) { + FocusData data = dataFor(player); + if (data == null) { + continue; + } + data.applyRegenForElapsed(hourlyRate(player), intervalMs, now); + } + } + + private static double hourlyRate(Player player) { + double hourly = FocusConfig.basePerHour; + for (FocusConfig.RegenBonus bonus : FocusConfig.regenBonuses) { + hourly += FocusAttributes.getTotal(player, bonus.mmocoreId) * bonus.extraPerHourPerPoint; + } + return hourly; + } + + private void stopRegen() { + if (regenTask != null) { + regenTask.cancel(); + regenTask = null; + } + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/focus/FocusStore.java b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusStore.java new file mode 100644 index 0000000..7ed7ee2 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusStore.java @@ -0,0 +1,87 @@ +package net.tfminecraft.rpcharacters.focus; + +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +public final class FocusStore { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private final File folder; + + public FocusStore(File folder) { + this.folder = folder; + } + + /** Null means absent, never an unreadable or malformed existing character record. */ + public FocusData load(String characterId) { + if (characterId == null || characterId.isBlank()) return null; + File file = fileFor(characterId); + if (Files.notExists(file.toPath())) return null; + try (Reader reader = new FileReader(file)) { + JsonObject object = JsonParser.parseReader(reader).getAsJsonObject(); + exactNonnegativeNumber(object, "points").intValueExact(); + exactNonnegativeNumber(object, "lastRegenMs").longValueExact(); + FocusData data = GSON.fromJson(object, FocusData.class); + if (data.getCharacterId() == null || data.getCharacterId().isBlank()) { + data.setCharacterId(characterId); + } else if (!characterId.equals(data.getCharacterId())) { + throw new IllegalStateException("Character ID does not match filename"); + } + return data; + } catch (IOException | RuntimeException ex) { + throw failure("load", file, ex); + } + } + + public void save(FocusData data) { + if (data == null || data.getCharacterId() == null || data.getCharacterId().isBlank()) return; + File target = fileFor(data.getCharacterId()); + java.nio.file.Path temp = null; + try { + Files.createDirectories(folder.toPath()); + temp = Files.createTempFile(folder.toPath(), ".focus-", ".tmp"); + try (Writer writer = new FileWriter(temp.toFile())) { + GSON.toJson(data, writer); + } + Files.move(temp, target.toPath(), StandardCopyOption.REPLACE_EXISTING); + } catch (IOException | RuntimeException ex) { + throw failure("save", target, ex); + } finally { + if (temp != null) { + try { Files.deleteIfExists(temp); } catch (IOException ignored) { /* Reported primary failure. */ } + } + } + } + + private static java.math.BigDecimal exactNonnegativeNumber(JsonObject object, String field) { + var value = object.get(field); + if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) { + throw new IllegalStateException("Missing or nonnumeric " + field); + } + var number = value.getAsBigDecimal(); + if (number.signum() < 0) throw new IllegalStateException("Negative " + field); + return number; + } + + private static IllegalStateException failure(String operation, File file, Exception cause) { + return new IllegalStateException("Cannot " + operation + " focus file " + file + + "; existing state will not be replaced: " + cause.getMessage(), cause); + } + + private File fileFor(String characterId) { + if (characterId.indexOf('/') >= 0 || characterId.indexOf('\\') >= 0) { + throw new IllegalArgumentException("Character ID must not contain a path separator"); + } + return new File(folder, characterId + ".json"); + } +} diff --git a/src/main/resources/focus.yml b/src/main/resources/focus.yml new file mode 100644 index 0000000..8670d69 --- /dev/null +++ b/src/main/resources/focus.yml @@ -0,0 +1,14 @@ +# Character-keyed mental points (Focus). Not stats. +# Consumed by Research experiments and Magic meditation. + +max: 150 +base_per_hour: 10 +regen_interval_ticks: 72000 +offline_regen: true + +# MMOCore attribute bonuses to hourly regen. +regen_bonuses: + - mmocore_id: wisdom + extra_per_hour_per_point: 0.5 + - mmocore_id: intelligence + extra_per_hour_per_point: 0.25 diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 56e6575..df7261d 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -8,6 +8,10 @@ depend: [TLibs, MMOCore, TFMCWeb, MMOItems, MythicLib] softdepend: [ProtocolLib, PlaceholderAPI, LuckPerms, WorldGuard, SimpleFactions] commands: + focus: + description: Manage character focus + usage: /focus restore | /focus reload + permission: rpchar.focus.admin rpcharacter: useage: / description: RPCharacters Command @@ -32,6 +36,9 @@ commands: usage: / unlock description: Unlock your newest grave so others can loot it permissions: + rpchar.focus.admin: + description: Restore character focus and reload focus configuration + default: op rpchar.profession.use: default: true rpchar.pvp: diff --git a/src/test/java/net/tfminecraft/rpcharacters/focus/FocusCommandTest.java b/src/test/java/net/tfminecraft/rpcharacters/focus/FocusCommandTest.java new file mode 100644 index 0000000..cb3c3d4 --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/focus/FocusCommandTest.java @@ -0,0 +1,95 @@ +package net.tfminecraft.rpcharacters.focus; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; + +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.rpcharacters.RPCharacters; + +class FocusCommandTest { + RPCharacters plugin; + CommandSender sender; + Command command; + FocusCommand handler; + + @BeforeEach void setup() { + plugin = mock(RPCharacters.class); + sender = mock(CommandSender.class); + command = mock(Command.class); + handler = new FocusCommand(plugin); + } + + @Test + void unauthorizedSenderCannotExecuteOrCompleteCommands() { + handler.onCommand(sender, command, "focus", new String[] {"reload"}); + verify(plugin, never()).reloadFocusConfig(); + assertEquals(List.of(), handler.onTabComplete(sender, command, "focus", new String[] {"restore", ""})); + verify(sender).sendMessage("You do not have permission to use this command."); + } + + @Test + void reloadReportsFailureAndSuccessAccurately() { + when(sender.hasPermission(FocusCommand.PERMISSION)).thenReturn(true); + when(plugin.reloadFocusConfig()).thenReturn(false, true); + handler.onCommand(sender, command, "focus", new String[] {"reload"}); + verify(sender).sendMessage(contains("did not reload")); + handler.onCommand(sender, command, "focus", new String[] {"reload"}); + verify(sender).sendMessage("Focus configuration reloaded."); + } + + @Test + void restoreUsesCurrentOwnerAndReportsTheResult() { + when(sender.hasPermission(FocusCommand.PERMISSION)).thenReturn(true); + var target = mock(Player.class); + when(target.getName()).thenReturn("Alice"); + var service = mock(FocusService.class); + when(service.restore(target)).thenReturn(true, false); + when(service.getPoints(target)).thenReturn(150); + when(service.getMax()).thenReturn(150); + try (var bukkit = mockStatic(Bukkit.class); var rpc = mockStatic(RPCharacters.class)) { + bukkit.when(() -> Bukkit.getPlayerExact("Alice")).thenReturn(target); + rpc.when(RPCharacters::getFocusService).thenReturn(service); + handler.onCommand(sender, command, "focus", new String[] {"restore", "Alice"}); + verify(sender).sendMessage("Restored focus for Alice (150/150)."); + handler.onCommand(sender, command, "focus", new String[] {"restore", "Alice"}); + verify(sender).sendMessage(contains("Could not restore focus for Alice")); + handler.onCommand(sender, command, "focus", new String[] {"restore", "Offline"}); + verify(sender).sendMessage("Player not found: Offline"); + } + } + + @Test + void unavailableOwnerDoesNotReportSuccessfulRestore() { + when(sender.hasPermission(FocusCommand.PERMISSION)).thenReturn(true); + var target = mock(Player.class); + when(target.getName()).thenReturn("Alice"); + try (var bukkit = mockStatic(Bukkit.class); var rpc = mockStatic(RPCharacters.class)) { + bukkit.when(() -> Bukkit.getPlayerExact("Alice")).thenReturn(target); + handler.onCommand(sender, command, "focus", new String[] {"restore", "Alice"}); + verify(sender).sendMessage(contains("Could not restore focus for Alice")); + } + } + + @Test + void completionsRespectCommandPositionAndPrefix() { + when(sender.hasPermission(FocusCommand.PERMISSION)).thenReturn(true); + assertEquals(List.of("restore", "reload"), handler.onTabComplete(sender, command, "focus", new String[] {"re"})); + assertEquals(List.of(), handler.onTabComplete(sender, command, "focus", new String[] {"reload", ""})); + var alice = mock(Player.class); + var bob = mock(Player.class); + when(alice.getName()).thenReturn("Alice"); + when(bob.getName()).thenReturn("Bob"); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(alice, bob)); + assertEquals(List.of("Alice"), handler.onTabComplete(sender, command, "focus", new String[] {"restore", "a"})); + } + } +} diff --git a/src/test/java/net/tfminecraft/rpcharacters/focus/FocusDataTest.java b/src/test/java/net/tfminecraft/rpcharacters/focus/FocusDataTest.java new file mode 100644 index 0000000..3d579e9 --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/focus/FocusDataTest.java @@ -0,0 +1,33 @@ +package net.tfminecraft.rpcharacters.focus; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class FocusDataTest { + @BeforeEach void reset() { FocusConfig.max = 150; } + + @Test + void regeneratesWholeIntervalsAndPreservesRemainder() { + FocusData data = FocusData.createNew("alice", "owner"); + data.setPoints(20); + data.setLastRegenMs(1000); + assertEquals(0, data.applyRegenForElapsed(10, 3_600_000, 3_600_999)); + assertEquals(20, data.applyRegenForElapsed(10, 3_600_000, 7_800_000)); + assertEquals(40, data.getPoints()); + assertEquals(7_201_000, data.getLastRegenMs()); + } + + @Test + void spendingGrantsAndFullRegenerationKeepExistingLimits() { + FocusData data = FocusData.createNew("alice", "owner"); + assertFalse(data.trySpend(151)); + assertTrue(data.trySpend(140)); + assertEquals(10, data.getPoints()); + data.grant(1000); + assertEquals(150, data.getPoints()); + assertEquals(0, data.applyRegenForElapsed(10, 3_600_000, 99)); + assertEquals(99, data.getLastRegenMs()); + } +} diff --git a/src/test/java/net/tfminecraft/rpcharacters/focus/FocusLifecycleTest.java b/src/test/java/net/tfminecraft/rpcharacters/focus/FocusLifecycleTest.java new file mode 100644 index 0000000..8015250 --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/focus/FocusLifecycleTest.java @@ -0,0 +1,232 @@ +package net.tfminecraft.rpcharacters.focus; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.plugin.PluginManager; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; +import org.mockito.MockedStatic; +import org.mockito.ArgumentCaptor; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.rpcharacters.lifecycle.CharacterActivatedEvent; +import net.tfminecraft.rpcharacters.objects.RPCharacter; + +class FocusLifecycleTest { + @TempDir Path root; + JavaPlugin plugin; + Player player; + FocusStore store; + MockedStatic characters; + final AtomicReference active = new AtomicReference<>(); + + @BeforeEach void setup() { + FocusConfig.max = 150; + FocusConfig.basePerHour = 10; + FocusConfig.regenIntervalTicks = 72_000; + FocusConfig.offlineRegen = false; + FocusConfig.regenBonuses.clear(); + plugin = mock(JavaPlugin.class); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + when(plugin.getDataFolder()).thenReturn(root.resolve("RPCharacters").toFile()); + player = mock(Player.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + store = new FocusStore(root.resolve("RPCharacters/data/focus").toFile()); + characters = mockStatic(RPCharacters.class); + characters.when(() -> RPCharacters.getActiveCharacter(player)).thenAnswer(invocation -> active.get()); + } + + @AfterEach void cleanup() { characters.close(); } + + private RPCharacter character(String id) { + RPCharacter character = mock(RPCharacter.class); + when(character.getId()).thenReturn(id); + return character; + } + + @Test + void switchAndQuitPersistIndependentCharacterBalances() { + var service = new FocusService(plugin, store); + var listener = new FocusListener(service); + var alice = character("alice"); + var bob = character("bob"); + active.set(alice); + listener.onCharacterActivated(new CharacterActivatedEvent(player, player.getUniqueId(), alice, null)); + assertTrue(service.trySpend(player, 40)); + active.set(bob); + listener.onCharacterActivated(new CharacterActivatedEvent(player, player.getUniqueId(), bob, alice)); + assertEquals(110, store.load("alice").getPoints()); + assertEquals(150, service.getPoints(player)); + service.trySpend(player, 10); + listener.onQuit(new PlayerQuitEvent(player, (String) null)); + assertEquals(140, store.load("bob").getPoints()); + assertEquals(0, service.getPoints(player)); + active.set(alice); + service.activate(player, alice); + assertEquals(110, service.getPoints(player)); + } + + @Test + void failedActivationCannotReusePreviousCharacterOrOverwriteCorruptData() throws Exception { + var service = new FocusService(plugin, store); + active.set(character("alice")); + service.activate(player, active.get()); + Path bad = root.resolve("RPCharacters/data/focus/bob.json"); + Files.writeString(bad, "broken"); + active.set(character("bob")); + service.activate(player, active.get()); + assertEquals(0, service.getPoints(player)); + assertFalse(service.trySpend(player, 1)); + service.deactivate(player); + assertEquals("broken", Files.readString(bad)); + verify(plugin.getLogger()).severe(contains("Focus unavailable for character bob")); + } + + @Test + void offlineRegenSettingControlsActivationWithoutChangingStoredTimestampOnRead() { + var data = FocusData.createNew("alice", player.getUniqueId().toString()); + data.setPoints(20); + data.setLastRegenMs(System.currentTimeMillis() - 7_200_500); + store.save(data); + var service = new FocusService(plugin, store); + FocusConfig.offlineRegen = true; + active.set(character("alice")); + service.activate(player, active.get()); + assertEquals(40, service.getPoints(player)); + FocusConfig.offlineRegen = false; + active.set(character("alice")); + service.activate(player, active.get()); + assertEquals(20, service.getPoints(player)); + service.deactivate(player); + assertTrue(System.currentTimeMillis() - store.load("alice").getLastRegenMs() < 5000); + } + + @Test + void everyPublicOperationEvictsAndSavesAnInactiveCharactersBalance() { + for (String operation : List.of("get", "spend", "grant", "restore")) { + var service = new FocusService(plugin, store); + var character = character(operation); + active.set(character); + service.activate(player, character); + assertTrue(service.trySpend(player, 40)); + active.set(null); + switch (operation) { + case "get" -> assertEquals(0, service.getPoints(player)); + case "spend" -> assertFalse(service.trySpend(player, 1)); + case "grant" -> service.grant(player, 1); + case "restore" -> assertFalse(service.restore(player)); + } + assertEquals(110, store.load(operation).getPoints()); + active.set(character); + assertEquals(0, service.getPoints(player), "Inactive state must be evicted, not merely hidden"); + service.activate(player, character); + assertEquals(110, service.getPoints(player)); + } + } + + @Test + void aDifferentActiveCharacterCannotSpendTheCachedCharactersFocus() { + var service = new FocusService(plugin, store); + var alice = character("alice"); + active.set(alice); + service.activate(player, alice); + service.trySpend(player, 40); + active.set(character("bob")); + assertFalse(service.trySpend(player, 1)); + assertEquals(110, store.load("alice").getPoints()); + } + + @Test + void timerAndSaveAllEvictInactiveCharactersWithoutRegenerating() { + var scheduler = mock(BukkitScheduler.class); + when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), anyLong(), anyLong())) + .thenReturn(mock(BukkitTask.class)); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(player)); + for (boolean timer : List.of(true, false)) { + var trackedStore = mock(FocusStore.class); + var data = FocusData.createNew("alice", player.getUniqueId().toString()); + data.setPoints(20); + when(trackedStore.load("alice")).thenReturn(data); + var service = new FocusService(plugin, trackedStore); + var alice = character("alice"); + active.set(alice); + service.activate(player, alice); + data.setLastRegenMs(System.currentTimeMillis() - 7_200_500); + long lastRegen = data.getLastRegenMs(); + active.set(null); + if (timer) { + service.restartRegen(); + var callback = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTaskTimer(eq(plugin), callback.capture(), anyLong(), anyLong()); + callback.getValue().run(); + } else { + service.saveAllOnline(); + } + assertEquals(20, data.getPoints()); + assertEquals(lastRegen, data.getLastRegenMs()); + verify(trackedStore).save(data); + active.set(alice); + assertEquals(0, service.getPoints(player)); + service.shutdown(); + } + } + } + + @Test + void startupReloadAndShutdownHaveOneTimerAndSaveBalance() throws Exception { + var server = mock(Server.class); + var manager = mock(PluginManager.class); + var scheduler = mock(BukkitScheduler.class); + var first = mock(BukkitTask.class); + var second = mock(BukkitTask.class); + when(plugin.getServer()).thenReturn(server); + when(server.getPluginManager()).thenReturn(manager); + Path config = root.resolve("RPCharacters/focus.yml"); + Files.createDirectories(config.getParent()); + Files.writeString(config, "max: 150\nregen_interval_ticks: 72000\noffline_regen: false\n"); + when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), anyLong(), anyLong())).thenReturn(first, second); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of()); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + var module = new FocusModule(plugin); + assertTrue(module.start()); + assertTrue(module.start()); + FocusService service = module.getService(); + active.set(character("alice")); + service.activate(player, active.get()); + service.trySpend(player, 30); + Files.writeString(config, "max: 180\nregen_interval_ticks: 100\noffline_regen: false\n"); + assertTrue(module.reloadConfig()); + assertSame(service, module.getService()); + assertEquals(180, service.getMax()); + verify(first).cancel(); + verify(scheduler).runTaskTimer(eq(plugin), any(Runnable.class), eq(100L), eq(100L)); + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(player)); + module.shutdown(); + verify(second).cancel(); + verify(manager, times(1)).registerEvents(any(FocusListener.class), eq(plugin)); + assertEquals(120, store.load("alice").getPoints()); + assertNull(module.getService()); + } + } + +} diff --git a/src/test/java/net/tfminecraft/rpcharacters/focus/FocusStoreTest.java b/src/test/java/net/tfminecraft/rpcharacters/focus/FocusStoreTest.java new file mode 100644 index 0000000..a2da17c --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/focus/FocusStoreTest.java @@ -0,0 +1,85 @@ +package net.tfminecraft.rpcharacters.focus; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class FocusStoreTest { + @TempDir Path root; + + @Test + void preservesCharacterMetadataAndTimestampWhenSavingBalance() throws Exception { + Path folder = root.resolve("focus"); + Files.createDirectories(folder); + Files.writeString(folder.resolve("alice.json"), + "{\"characterId\":\"alice\",\"ownerUuid\":\"account\",\"points\":42,\"lastRegenMs\":1234}"); + var store = new FocusStore(folder.toFile()); + FocusData data = store.load("alice"); + assertEquals(42, data.getPoints()); + data.setPoints(17); + store.save(data); + FocusData saved = store.load("alice"); + assertEquals(17, saved.getPoints()); + assertEquals("alice", saved.getCharacterId()); + assertEquals("account", saved.getOwnerUuid()); + assertEquals(1234, saved.getLastRegenMs()); + } + + @Test + void corruptExistingRecordIsAnErrorNotAbsentOrFreshState() throws Exception { + Path folder = root.resolve("focus"); + Files.createDirectories(folder); + Path file = folder.resolve("alice.json"); + var store = new FocusStore(folder.toFile()); + for (String invalid : new String[] {"broken", "null", "{}", "{\"characterId\":\"other\",\"points\":42,\"lastRegenMs\":1}"}) { + Files.writeString(file, invalid); + assertThrows(IllegalStateException.class, () -> store.load("alice")); + assertEquals(invalid, Files.readString(file)); + } + assertNull(store.load("missing")); + } + + @Test + void rejectsPathSeparatorsForReadsAndWritesWithoutTouchingOtherFiles() throws Exception { + Path folder = root.resolve("focus"); + Files.createDirectories(folder); + Path outside = root.resolve("outside.json"); + String contents = "{\"characterId\":\"../outside\",\"points\":42,\"lastRegenMs\":1}"; + Files.writeString(outside, contents); + var store = new FocusStore(folder.toFile()); + for (String id : new String[] {"../outside", "nested/child", "nested\\child", "/absolute"}) { + assertThrows(IllegalArgumentException.class, () -> store.load(id)); + FocusData data = FocusData.createNew(id, "account"); + assertThrows(IllegalArgumentException.class, () -> store.save(data)); + assertEquals(contents, Files.readString(outside)); + } + try (var files = Files.list(folder)) { + assertEquals(0, files.count()); + } + } + + @Test + void nullFractionalAndOutOfRangeBalancesAndTimestampsAreRejected() throws Exception { + Path folder = root.resolve("focus"); + Files.createDirectories(folder); + Path file = folder.resolve("alice.json"); + var store = new FocusStore(folder.toFile()); + for (String points : new String[] {"null", "\"12\"", "1.5", "2147483648", "-1"}) { + String invalid = "{\"points\":" + points + ",\"lastRegenMs\":100}"; + Files.writeString(file, invalid); + assertThrows(IllegalStateException.class, () -> store.load("alice")); + assertEquals(invalid, Files.readString(file)); + } + for (String timestamp : new String[] {"null", "\"12\"", "1.5", "9223372036854775808", "-1"}) { + String invalid = "{\"points\":12,\"lastRegenMs\":" + timestamp + "}"; + Files.writeString(file, invalid); + assertThrows(IllegalStateException.class, () -> store.load("alice")); + assertEquals(invalid, Files.readString(file)); + } + } + +}