From 9e1ed6d6ad890abfa7d15028060ca4e040f80749 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:47:23 +0000 Subject: [PATCH 1/4] refactor: own character focus in RPCharacters --- README.md | 33 +++ .../rpcharacters/RPCharacters.java | 16 ++ .../rpcharacters/focus/FocusAttributes.java | 24 +++ .../rpcharacters/focus/FocusConfig.java | 25 +++ .../rpcharacters/focus/FocusConfigLoader.java | 46 +++++ .../rpcharacters/focus/FocusData.java | 95 +++++++++ .../rpcharacters/focus/FocusListener.java | 34 +++ .../rpcharacters/focus/FocusMigration.java | 39 ++++ .../rpcharacters/focus/FocusModule.java | 61 ++++++ .../rpcharacters/focus/FocusOwnership.java | 30 +++ .../rpcharacters/focus/FocusService.java | 193 ++++++++++++++++++ .../rpcharacters/focus/FocusStore.java | 114 +++++++++++ src/main/resources/focus.yml | 14 ++ .../rpcharacters/focus/FocusDataTest.java | 33 +++ .../focus/FocusLifecycleTest.java | 171 ++++++++++++++++ .../focus/FocusMigrationTest.java | 108 ++++++++++ 16 files changed, 1036 insertions(+) create mode 100644 src/main/java/net/tfminecraft/rpcharacters/focus/FocusAttributes.java create mode 100644 src/main/java/net/tfminecraft/rpcharacters/focus/FocusConfig.java create mode 100644 src/main/java/net/tfminecraft/rpcharacters/focus/FocusConfigLoader.java create mode 100644 src/main/java/net/tfminecraft/rpcharacters/focus/FocusData.java create mode 100644 src/main/java/net/tfminecraft/rpcharacters/focus/FocusListener.java create mode 100644 src/main/java/net/tfminecraft/rpcharacters/focus/FocusMigration.java create mode 100644 src/main/java/net/tfminecraft/rpcharacters/focus/FocusModule.java create mode 100644 src/main/java/net/tfminecraft/rpcharacters/focus/FocusOwnership.java create mode 100644 src/main/java/net/tfminecraft/rpcharacters/focus/FocusService.java create mode 100644 src/main/java/net/tfminecraft/rpcharacters/focus/FocusStore.java create mode 100644 src/main/resources/focus.yml create mode 100644 src/test/java/net/tfminecraft/rpcharacters/focus/FocusDataTest.java create mode 100644 src/test/java/net/tfminecraft/rpcharacters/focus/FocusLifecycleTest.java create mode 100644 src/test/java/net/tfminecraft/rpcharacters/focus/FocusMigrationTest.java diff --git a/README.md b/README.md index 05ad38c..de44a82 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. @@ -25,6 +26,38 @@ The character pages in [ProvinceSystem](https://github.com/TF-Minecraft/Province Technical documentation is maintained in [TF-Minecraft/Docs](https://github.com/TF-Minecraft/Docs). +## Focus ownership migration + +RPCharacters now owns `focus.yml`, `data/focus/.json`, character +activation/quit handling, and the regeneration timer. Consumers use +`RPCharacters.getFocusService()`; the getter returns `null` if ownership or startup +checks prevent focus from starting. `/rpcharacter reload` reloads `focus.yml` and +restarts the single regeneration timer. The existing point limits, attribute +bonuses, offline regeneration, and legacy Research import retain their behavior. + +Deploy RPCharacters 2.1.0 with the matching TFMCCore build declaring +`feature-owners.focus: RPCharacters` in its bundled `plugin.yml`, plus the updated +Research/Magic consumers. Stage the full set and restart the server together. +RPCharacters checks installed Core even before Core enables; an older Core causes +RPCharacters focus to stand down with an actionable log message, preventing two +writers/timers. RPCharacters does not need Core to be installed. + +On startup, missing focus configuration and character JSON files are copied from +TFMCCore's data directory (or the sibling `TFMCCore` directory when Core is absent). +Existing RPCharacters files always win. Originals are retained, partial copies +can be retried, and failed configuration/file copies prevent focus startup. +Malformed or unreadable character records leave that character's focus unavailable +and are logged; they are not replaced with fresh points. Repair the reported file +before reactivating the character. Legacy Research `mental_points` and +`last_regen_ms` remain an import source only when no focus record exists. + +For rollback, stop the server and retain backups of both directories. Before +restoring the previous plugin set, copy the latest RPCharacters focus config and +character records back to TFMCCore (review conflicts first). The preserved Core +copies become stale as soon as players spend or regenerate points under the new +owner; restoring only old JARs would lose those later changes. No old state is +automatically deleted. + ## License Copyright (c) 2026 TF-Minecraft contributors. diff --git a/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java b/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java index 7e0516d..c189282 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,8 @@ public void onEnable() { GraveManager.get().loadAll(); MailRecipientDirectory.scanFromDisk(); loadPlayers(); + focusModule = new net.tfminecraft.rpcharacters.focus.FocusModule(this); + focusModule.start(); startManagers(); getCommand(commandManager.cmd1).setExecutor(commandManager); getCommand("rpcharacter").setTabCompleter(new CommandTabCompleter()); @@ -253,6 +256,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(); @@ -491,6 +495,9 @@ public void createConfigs() { public void reload() { loadConfigs(); + if (!reloadFocusConfig()) { + throw new IllegalStateException("Focus configuration did not reload; see the preceding focus error."); + } LastSolidTracker.get().start(); ProfessionCommandHandler.reapplyActiveCharacterPerms(); // Catalog + pending pull already run inside loadConfigs(); also refresh website sheets. @@ -518,6 +525,15 @@ public void reloadConfigs(CommandSender sender) { } } + /** Character focus; null when ownership or migration checks prevent startup. */ + 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/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/FocusMigration.java b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusMigration.java new file mode 100644 index 0000000..a186230 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusMigration.java @@ -0,0 +1,39 @@ +package net.tfminecraft.rpcharacters.focus; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** Copies legacy files without replacing either owner's existing state. */ +final class FocusMigration { + private FocusMigration() {} + + static void copyLegacy(Path legacy, Path destination) throws IOException { + copyMissing(legacy.resolve("focus.yml"), destination.resolve("focus.yml")); + Path source = legacy.resolve("data/focus"); + if (Files.notExists(source)) return; + try (var files = Files.list(source)) { + for (Path file : files.filter(p -> p.getFileName().toString().endsWith(".json")).toList()) { + copyMissing(file, destination.resolve("data/focus").resolve(file.getFileName())); + } + } + } + + private static void copyMissing(Path source, Path destination) throws IOException { + if (Files.notExists(source)) return; + if (!Files.notExists(destination)) { + if (!Files.isRegularFile(destination)) throw new IOException("Not a regular file: " + destination); + return; + } + if (!Files.isRegularFile(source)) throw new IOException("Not a regular file: " + source); + Files.createDirectories(destination.getParent()); + Path temporary = Files.createTempFile(destination.getParent(), ".focus-migration-", ".tmp"); + try { + Files.copy(source, temporary, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + // No REPLACE_EXISTING: a retry must never clobber state already owned by RPCharacters. + Files.move(temporary, destination); + } finally { + Files.deleteIfExists(temporary); + } + } +} 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..9f303ed --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusModule.java @@ -0,0 +1,61 @@ +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; + var core = plugin.getServer().getPluginManager().getPlugin("TFMCCore"); + if (!FocusOwnership.canStart(core, plugin.getLogger())) return false; + try { + File legacy = core == null ? new File(plugin.getDataFolder().getParentFile(), "TFMCCore") + : core.getDataFolder(); + FocusMigration.copyLegacy(legacy.toPath(), plugin.getDataFolder().toPath()); + 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"), + new File(plugin.getDataFolder().getParentFile(), "Research/data/players")); + service = new FocusService(plugin, store); + listener = new FocusListener(service); + plugin.getServer().getPluginManager().registerEvents(listener, plugin); + service.start(); + return true; + } catch (RuntimeException | java.io.IOException ex) { + plugin.getLogger().severe("Focus startup stopped; existing data was preserved. Repair the reported " + + "file or ownership 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/FocusOwnership.java b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusOwnership.java new file mode 100644 index 0000000..0877123 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusOwnership.java @@ -0,0 +1,30 @@ +package net.tfminecraft.rpcharacters.focus; + +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.logging.Logger; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.Plugin; + +final class FocusOwnership { + private FocusOwnership() {} + + static boolean canStart(Plugin core, Logger logger) { + if (core == null) return true; + // Core loads after RPCharacters; isPluginEnabled would miss an installed old owner. + try (var resource = core.getResource("plugin.yml")) { + if (resource != null) { + var descriptor = new YamlConfiguration(); + descriptor.load(new InputStreamReader(resource, StandardCharsets.UTF_8)); + if ("RPCharacters".equals(descriptor.getString("feature-owners.focus"))) return true; + } + } catch (Exception ex) { + logger.warning("Cannot read TFMCCore focus ownership: " + ex.getMessage()); + } + logger.severe("RPCharacters focus is disabled: installed TFMCCore still owns focus or lacks " + + "feature-owners.focus: RPCharacters. Install the matching migrated TFMCCore build " + + "(or remove Core) and restart before using focus consumers."); + return false; + } +} 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..3903bc8 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusService.java @@ -0,0 +1,193 @@ +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 = store.migrateFromResearch(characterId, owner); + 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 = loaded.get(player.getUniqueId()); + 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) { + return player == null ? null : loaded.get(player.getUniqueId()); + } + + 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 = loaded.get(player.getUniqueId()); + 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..27bea42 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusStore.java @@ -0,0 +1,114 @@ +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; + private final File researchFolder; + + public FocusStore(File folder) { + this(folder, new File("plugins/Research/data/players")); + } + + FocusStore(File folder, File researchFolder) { + this.folder = folder; + this.researchFolder = researchFolder; + } + + /** 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. */ } + } + } + } + + FocusData migrateFromResearch(String characterId, String ownerUuid) { + return migrateFromResearch(researchFolder, characterId, ownerUuid); + } + + public static FocusData tryMigrateFromResearch(String characterId, String ownerUuid) { + return migrateFromResearch(new File("plugins/Research/data/players"), characterId, ownerUuid); + } + + private static FocusData migrateFromResearch(File researchFolder, String characterId, String ownerUuid) { + if (ownerUuid == null || ownerUuid.isBlank()) return null; + File researchFile = new File(researchFolder, ownerUuid + ".json"); + if (Files.notExists(researchFile.toPath())) return null; + try (Reader reader = new FileReader(researchFile)) { + JsonObject object = JsonParser.parseReader(reader).getAsJsonObject(); + if (!object.has("mental_points")) return null; + FocusData data = FocusData.createNew(characterId, ownerUuid); + data.setPoints(exactNonnegativeNumber(object, "mental_points").intValueExact()); + if (object.has("last_regen_ms")) data.setLastRegenMs(exactNonnegativeNumber(object, "last_regen_ms").longValueExact()); + return data; + } catch (IOException | RuntimeException ex) { + throw failure("import legacy Research data from", researchFile, ex); + } + } + + 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) { + 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/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..623af9f --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/focus/FocusLifecycleTest.java @@ -0,0 +1,171 @@ +package net.tfminecraft.rpcharacters.focus; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; +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.Plugin; +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.Test; +import org.junit.jupiter.api.io.TempDir; + +import net.tfminecraft.rpcharacters.lifecycle.CharacterActivatedEvent; +import net.tfminecraft.rpcharacters.objects.RPCharacter; + +class FocusLifecycleTest { + @TempDir Path root; + JavaPlugin plugin; + Player player; + FocusStore store; + + @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(), root.resolve("Research").toFile()); + } + + 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"); + listener.onCharacterActivated(new CharacterActivatedEvent(player, player.getUniqueId(), alice, null)); + assertTrue(service.trySpend(player, 40)); + 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)); + service.activate(player, alice); + assertEquals(110, service.getPoints(player)); + } + + @Test + void failedActivationCannotReusePreviousCharacterOrOverwriteCorruptData() throws Exception { + var service = new FocusService(plugin, store); + service.activate(player, character("alice")); + Path bad = root.resolve("RPCharacters/data/focus/bob.json"); + Files.writeString(bad, "broken"); + service.activate(player, character("bob")); + 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; + service.activate(player, character("alice")); + assertEquals(40, service.getPoints(player)); + FocusConfig.offlineRegen = false; + service.activate(player, character("alice")); + assertEquals(20, service.getPoints(player)); + service.deactivate(player); + assertTrue(System.currentTimeMillis() - store.load("alice").getLastRegenMs() < 5000); + } + + @Test + void disabledOldCoreStillBlocksStartupBeforeAnyDataOrListeners() { + var core = mock(Plugin.class); + when(core.getResource("plugin.yml")).thenReturn(descriptor("name: TFMCCore\n")); + var server = mock(Server.class); + var manager = mock(PluginManager.class); + when(plugin.getServer()).thenReturn(server); + when(server.getPluginManager()).thenReturn(manager); + when(manager.getPlugin("TFMCCore")).thenReturn(core); + assertFalse(new FocusModule(plugin).start()); + verify(core, never()).isEnabled(); + verify(manager, never()).registerEvents(any(), any()); + assertFalse(Files.exists(root.resolve("RPCharacters"))); + } + + @Test + void markerAllowsHandoffAndAbsentCoreAllowsStandaloneOwner() { + var core = mock(Plugin.class); + when(core.getResource("plugin.yml")).thenReturn(descriptor("feature-owners:\n focus: RPCharacters\n")); + assertTrue(FocusOwnership.canStart(core, plugin.getLogger())); + assertTrue(FocusOwnership.canStart(null, plugin.getLogger())); + when(core.getResource("plugin.yml")).thenReturn(descriptor("feature-owners: [broken")); + assertFalse(FocusOwnership.canStart(core, plugin.getLogger())); + } + + @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(); + service.activate(player, character("alice")); + 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()); + } + } + + private ByteArrayInputStream descriptor(String text) { + return new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/test/java/net/tfminecraft/rpcharacters/focus/FocusMigrationTest.java b/src/test/java/net/tfminecraft/rpcharacters/focus/FocusMigrationTest.java new file mode 100644 index 0000000..67fe287 --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/focus/FocusMigrationTest.java @@ -0,0 +1,108 @@ +package net.tfminecraft.rpcharacters.focus; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class FocusMigrationTest { + @TempDir Path root; + + @Test + void copiesLegacyBytesAndNeverOverwritesEitherOwnerOnRetry() throws Exception { + Path legacy = root.resolve("TFMCCore"); + Path owner = root.resolve("RPCharacters"); + Files.createDirectories(legacy.resolve("data/focus")); + String record = "{\"characterId\":\"alice\",\"ownerUuid\":\"account\",\"points\":42,\"lastRegenMs\":1234}"; + Files.writeString(legacy.resolve("focus.yml"), "max: 190\nbase_per_hour: 13\n"); + Files.writeString(legacy.resolve("data/focus/alice.json"), record); + FocusMigration.copyLegacy(legacy, owner); + assertEquals(record, Files.readString(owner.resolve("data/focus/alice.json"))); + assertEquals(Files.readString(legacy.resolve("focus.yml")), Files.readString(owner.resolve("focus.yml"))); + var store = new FocusStore(owner.resolve("data/focus").toFile()); + FocusData data = store.load("alice"); + assertEquals(42, data.getPoints()); + assertEquals(1234, data.getLastRegenMs()); + data.setPoints(17); + store.save(data); + Files.writeString(owner.resolve("focus.yml"), "max: 180\n"); + FocusMigration.copyLegacy(legacy, owner); + assertEquals(17, store.load("alice").getPoints()); + assertEquals("max: 180\n", Files.readString(owner.resolve("focus.yml"))); + assertEquals(record, Files.readString(legacy.resolve("data/focus/alice.json"))); + } + + @Test + void partialMigrationCanRetryAfterFilesystemFailure() throws Exception { + Path legacy = root.resolve("TFMCCore"); + Path owner = root.resolve("RPCharacters"); + Files.createDirectories(legacy.resolve("data/focus")); + Files.writeString(legacy.resolve("focus.yml"), "max: 170\n"); + Files.writeString(legacy.resolve("data/focus/alice.json"), "{\"points\":7,\"lastRegenMs\":100}"); + Files.createDirectories(owner); + Files.writeString(owner.resolve("data"), "obstruction"); + assertThrows(IOException.class, () -> FocusMigration.copyLegacy(legacy, owner)); + assertEquals("max: 170\n", Files.readString(owner.resolve("focus.yml"))); + assertTrue(Files.isRegularFile(legacy.resolve("data/focus/alice.json"))); + Files.delete(owner.resolve("data")); + FocusMigration.copyLegacy(legacy, owner); + assertEquals(7, new FocusStore(owner.resolve("data/focus").toFile()).load("alice").getPoints()); + } + + @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 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)); + } + } + + @Test + void legacyResearchImportPreservesPointsTimestampAndSource() throws Exception { + Path research = root.resolve("research"); + Files.createDirectories(research); + Path old = research.resolve("account.json"); + String original = "{\"mental_points\":23,\"last_regen_ms\":7000}"; + Files.writeString(old, original); + var store = new FocusStore(root.resolve("focus").toFile(), research.toFile()); + FocusData data = store.migrateFromResearch("alice", "account"); + assertEquals("alice", data.getCharacterId()); + assertEquals("account", data.getOwnerUuid()); + assertEquals(23, data.getPoints()); + assertEquals(7000, data.getLastRegenMs()); + assertEquals(original, Files.readString(old)); + Files.writeString(old, "{invalid"); + assertThrows(IllegalStateException.class, () -> store.migrateFromResearch("alice", "account")); + assertNull(store.load("alice")); + } +} From 95c9b3ccc96e2dcb5b6989fc470e5342f63de71d Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:50:20 +0000 Subject: [PATCH 2/4] fix: continue character reload when focus stands down --- .../rpcharacters/RPCharacters.java | 21 ++++++++++++++----- .../focus/FocusLifecycleTest.java | 7 +++++-- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java b/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java index c189282..d65aae0 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java +++ b/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java @@ -494,14 +494,21 @@ public void createConfigs() { } public void reload() { + reloadWithFocusStatus(); + } + + private boolean reloadWithFocusStatus() { loadConfigs(); - if (!reloadFocusConfig()) { - throw new IllegalStateException("Focus configuration did not reload; see the preceding focus error."); + boolean focusReloaded = reloadFocusConfig(); + if (!focusReloaded) { + getLogger().warning("Focus did not reload; see the preceding ownership/configuration message. " + + "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) { @@ -511,10 +518,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 ownership/configuration message.")); } } catch (Exception e) { getLogger().severe("Config reload failed: " + e.getMessage()); diff --git a/src/test/java/net/tfminecraft/rpcharacters/focus/FocusLifecycleTest.java b/src/test/java/net/tfminecraft/rpcharacters/focus/FocusLifecycleTest.java index 623af9f..ee79302 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/focus/FocusLifecycleTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/focus/FocusLifecycleTest.java @@ -106,13 +106,16 @@ void offlineRegenSettingControlsActivationWithoutChangingStoredTimestampOnRead() @Test void disabledOldCoreStillBlocksStartupBeforeAnyDataOrListeners() { var core = mock(Plugin.class); - when(core.getResource("plugin.yml")).thenReturn(descriptor("name: TFMCCore\n")); + when(core.getResource("plugin.yml")).thenAnswer(invocation -> descriptor("name: TFMCCore\n")); var server = mock(Server.class); var manager = mock(PluginManager.class); when(plugin.getServer()).thenReturn(server); when(server.getPluginManager()).thenReturn(manager); when(manager.getPlugin("TFMCCore")).thenReturn(core); - assertFalse(new FocusModule(plugin).start()); + var module = new FocusModule(plugin); + assertFalse(module.start()); + assertFalse(assertDoesNotThrow(module::reloadConfig)); + assertNull(module.getService()); verify(core, never()).isEnabled(); verify(manager, never()).registerEvents(any(), any()); assertFalse(Files.exists(root.resolve("RPCharacters"))); From 0871ccafc2a984071c1828adbcb30ed070ad4df1 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:56:49 +0000 Subject: [PATCH 3/4] refactor: manage focus directly without migration support --- README.md | 32 ------ .../rpcharacters/RPCharacters.java | 9 +- .../rpcharacters/focus/FocusCommand.java | 69 +++++++++++ .../rpcharacters/focus/FocusMigration.java | 39 ------- .../rpcharacters/focus/FocusModule.java | 12 +- .../rpcharacters/focus/FocusOwnership.java | 30 ----- .../rpcharacters/focus/FocusService.java | 3 +- .../rpcharacters/focus/FocusStore.java | 30 ----- src/main/resources/plugin.yml | 7 ++ .../rpcharacters/focus/FocusCommandTest.java | 95 +++++++++++++++ .../focus/FocusLifecycleTest.java | 36 +----- .../focus/FocusMigrationTest.java | 108 ------------------ .../rpcharacters/focus/FocusStoreTest.java | 66 +++++++++++ 13 files changed, 248 insertions(+), 288 deletions(-) create mode 100644 src/main/java/net/tfminecraft/rpcharacters/focus/FocusCommand.java delete mode 100644 src/main/java/net/tfminecraft/rpcharacters/focus/FocusMigration.java delete mode 100644 src/main/java/net/tfminecraft/rpcharacters/focus/FocusOwnership.java create mode 100644 src/test/java/net/tfminecraft/rpcharacters/focus/FocusCommandTest.java delete mode 100644 src/test/java/net/tfminecraft/rpcharacters/focus/FocusMigrationTest.java create mode 100644 src/test/java/net/tfminecraft/rpcharacters/focus/FocusStoreTest.java diff --git a/README.md b/README.md index de44a82..5121583 100644 --- a/README.md +++ b/README.md @@ -26,38 +26,6 @@ The character pages in [ProvinceSystem](https://github.com/TF-Minecraft/Province Technical documentation is maintained in [TF-Minecraft/Docs](https://github.com/TF-Minecraft/Docs). -## Focus ownership migration - -RPCharacters now owns `focus.yml`, `data/focus/.json`, character -activation/quit handling, and the regeneration timer. Consumers use -`RPCharacters.getFocusService()`; the getter returns `null` if ownership or startup -checks prevent focus from starting. `/rpcharacter reload` reloads `focus.yml` and -restarts the single regeneration timer. The existing point limits, attribute -bonuses, offline regeneration, and legacy Research import retain their behavior. - -Deploy RPCharacters 2.1.0 with the matching TFMCCore build declaring -`feature-owners.focus: RPCharacters` in its bundled `plugin.yml`, plus the updated -Research/Magic consumers. Stage the full set and restart the server together. -RPCharacters checks installed Core even before Core enables; an older Core causes -RPCharacters focus to stand down with an actionable log message, preventing two -writers/timers. RPCharacters does not need Core to be installed. - -On startup, missing focus configuration and character JSON files are copied from -TFMCCore's data directory (or the sibling `TFMCCore` directory when Core is absent). -Existing RPCharacters files always win. Originals are retained, partial copies -can be retried, and failed configuration/file copies prevent focus startup. -Malformed or unreadable character records leave that character's focus unavailable -and are logged; they are not replaced with fresh points. Repair the reported file -before reactivating the character. Legacy Research `mental_points` and -`last_regen_ms` remain an import source only when no focus record exists. - -For rollback, stop the server and retain backups of both directories. Before -restoring the previous plugin set, copy the latest RPCharacters focus config and -character records back to TFMCCore (review conflicts first). The preserved Core -copies become stale as soon as players spend or regenerate points under the new -owner; restoring only old JARs would lose those later changes. No old state is -automatically deleted. - ## License Copyright (c) 2026 TF-Minecraft contributors. diff --git a/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java b/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java index d65aae0..a877a55 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java +++ b/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java @@ -234,6 +234,9 @@ public void onEnable() { 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()); @@ -501,7 +504,7 @@ private boolean reloadWithFocusStatus() { loadConfigs(); boolean focusReloaded = reloadFocusConfig(); if (!focusReloaded) { - getLogger().warning("Focus did not reload; see the preceding ownership/configuration message. " + getLogger().warning("Focus did not reload; see the preceding focus error. " + "Continuing the other RPCharacters reload steps."); } LastSolidTracker.get().start(); @@ -525,7 +528,7 @@ public void reloadConfigs(CommandSender sender) { if (sender != null) { RPTexts.sendPrefixed(sender, RPTexts.WARN + (focusReloaded ? "Reloading complete!" - : "Other configs reloaded; focus did not reload. Check console for the ownership/configuration message.")); + : "Other configs reloaded; focus did not reload. Check console for the focus error.")); } } catch (Exception e) { getLogger().severe("Config reload failed: " + e.getMessage()); @@ -536,7 +539,7 @@ public void reloadConfigs(CommandSender sender) { } } - /** Character focus; null when ownership or migration checks prevent startup. */ + /** Character focus; null when startup fails. */ public static net.tfminecraft.rpcharacters.focus.FocusService getFocusService() { return plugin == null || plugin.focusModule == null ? null : plugin.focusModule.getService(); } 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/FocusMigration.java b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusMigration.java deleted file mode 100644 index a186230..0000000 --- a/src/main/java/net/tfminecraft/rpcharacters/focus/FocusMigration.java +++ /dev/null @@ -1,39 +0,0 @@ -package net.tfminecraft.rpcharacters.focus; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; - -/** Copies legacy files without replacing either owner's existing state. */ -final class FocusMigration { - private FocusMigration() {} - - static void copyLegacy(Path legacy, Path destination) throws IOException { - copyMissing(legacy.resolve("focus.yml"), destination.resolve("focus.yml")); - Path source = legacy.resolve("data/focus"); - if (Files.notExists(source)) return; - try (var files = Files.list(source)) { - for (Path file : files.filter(p -> p.getFileName().toString().endsWith(".json")).toList()) { - copyMissing(file, destination.resolve("data/focus").resolve(file.getFileName())); - } - } - } - - private static void copyMissing(Path source, Path destination) throws IOException { - if (Files.notExists(source)) return; - if (!Files.notExists(destination)) { - if (!Files.isRegularFile(destination)) throw new IOException("Not a regular file: " + destination); - return; - } - if (!Files.isRegularFile(source)) throw new IOException("Not a regular file: " + source); - Files.createDirectories(destination.getParent()); - Path temporary = Files.createTempFile(destination.getParent(), ".focus-migration-", ".tmp"); - try { - Files.copy(source, temporary, java.nio.file.StandardCopyOption.REPLACE_EXISTING); - // No REPLACE_EXISTING: a retry must never clobber state already owned by RPCharacters. - Files.move(temporary, destination); - } finally { - Files.deleteIfExists(temporary); - } - } -} diff --git a/src/main/java/net/tfminecraft/rpcharacters/focus/FocusModule.java b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusModule.java index 9f303ed..4ec35da 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/focus/FocusModule.java +++ b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusModule.java @@ -17,25 +17,19 @@ public FocusModule(JavaPlugin plugin) { public boolean start() { if (service != null) return true; - var core = plugin.getServer().getPluginManager().getPlugin("TFMCCore"); - if (!FocusOwnership.canStart(core, plugin.getLogger())) return false; try { - File legacy = core == null ? new File(plugin.getDataFolder().getParentFile(), "TFMCCore") - : core.getDataFolder(); - FocusMigration.copyLegacy(legacy.toPath(), plugin.getDataFolder().toPath()); 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"), - new File(plugin.getDataFolder().getParentFile(), "Research/data/players")); + 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 | java.io.IOException ex) { + } catch (RuntimeException ex) { plugin.getLogger().severe("Focus startup stopped; existing data was preserved. Repair the reported " - + "file or ownership issue and reload RPCharacters: " + ex.getMessage()); + + "file issue and reload RPCharacters: " + ex.getMessage()); shutdown(); return false; } diff --git a/src/main/java/net/tfminecraft/rpcharacters/focus/FocusOwnership.java b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusOwnership.java deleted file mode 100644 index 0877123..0000000 --- a/src/main/java/net/tfminecraft/rpcharacters/focus/FocusOwnership.java +++ /dev/null @@ -1,30 +0,0 @@ -package net.tfminecraft.rpcharacters.focus; - -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; -import java.util.logging.Logger; - -import org.bukkit.configuration.file.YamlConfiguration; -import org.bukkit.plugin.Plugin; - -final class FocusOwnership { - private FocusOwnership() {} - - static boolean canStart(Plugin core, Logger logger) { - if (core == null) return true; - // Core loads after RPCharacters; isPluginEnabled would miss an installed old owner. - try (var resource = core.getResource("plugin.yml")) { - if (resource != null) { - var descriptor = new YamlConfiguration(); - descriptor.load(new InputStreamReader(resource, StandardCharsets.UTF_8)); - if ("RPCharacters".equals(descriptor.getString("feature-owners.focus"))) return true; - } - } catch (Exception ex) { - logger.warning("Cannot read TFMCCore focus ownership: " + ex.getMessage()); - } - logger.severe("RPCharacters focus is disabled: installed TFMCCore still owns focus or lacks " - + "feature-owners.focus: RPCharacters. Install the matching migrated TFMCCore build " - + "(or remove Core) and restart before using focus consumers."); - return false; - } -} diff --git a/src/main/java/net/tfminecraft/rpcharacters/focus/FocusService.java b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusService.java index 3903bc8..82bee31 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/focus/FocusService.java +++ b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusService.java @@ -96,8 +96,7 @@ public void activate(Player player, RPCharacter character) { try { FocusData data = store.load(characterId); if (data == null) { - data = store.migrateFromResearch(characterId, owner); - if (data == null) data = FocusData.createNew(characterId, owner); + data = FocusData.createNew(characterId, owner); store.save(data); } else { data.setOwnerUuid(owner); diff --git a/src/main/java/net/tfminecraft/rpcharacters/focus/FocusStore.java b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusStore.java index 27bea42..f05ee26 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/focus/FocusStore.java +++ b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusStore.java @@ -17,15 +17,9 @@ public final class FocusStore { private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); private final File folder; - private final File researchFolder; public FocusStore(File folder) { - this(folder, new File("plugins/Research/data/players")); - } - - FocusStore(File folder, File researchFolder) { this.folder = folder; - this.researchFolder = researchFolder; } /** Null means absent, never an unreadable or malformed existing character record. */ @@ -69,30 +63,6 @@ public void save(FocusData data) { } } - FocusData migrateFromResearch(String characterId, String ownerUuid) { - return migrateFromResearch(researchFolder, characterId, ownerUuid); - } - - public static FocusData tryMigrateFromResearch(String characterId, String ownerUuid) { - return migrateFromResearch(new File("plugins/Research/data/players"), characterId, ownerUuid); - } - - private static FocusData migrateFromResearch(File researchFolder, String characterId, String ownerUuid) { - if (ownerUuid == null || ownerUuid.isBlank()) return null; - File researchFile = new File(researchFolder, ownerUuid + ".json"); - if (Files.notExists(researchFile.toPath())) return null; - try (Reader reader = new FileReader(researchFile)) { - JsonObject object = JsonParser.parseReader(reader).getAsJsonObject(); - if (!object.has("mental_points")) return null; - FocusData data = FocusData.createNew(characterId, ownerUuid); - data.setPoints(exactNonnegativeNumber(object, "mental_points").intValueExact()); - if (object.has("last_regen_ms")) data.setLastRegenMs(exactNonnegativeNumber(object, "last_regen_ms").longValueExact()); - return data; - } catch (IOException | RuntimeException ex) { - throw failure("import legacy Research data from", researchFile, ex); - } - } - private static java.math.BigDecimal exactNonnegativeNumber(JsonObject object, String field) { var value = object.get(field); if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) { 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/FocusLifecycleTest.java b/src/test/java/net/tfminecraft/rpcharacters/focus/FocusLifecycleTest.java index ee79302..569c6a4 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/focus/FocusLifecycleTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/focus/FocusLifecycleTest.java @@ -3,8 +3,6 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; -import java.io.ByteArrayInputStream; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -15,7 +13,6 @@ import org.bukkit.Server; import org.bukkit.entity.Player; import org.bukkit.event.player.PlayerQuitEvent; -import org.bukkit.plugin.Plugin; import org.bukkit.plugin.PluginManager; import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.scheduler.BukkitScheduler; @@ -44,7 +41,7 @@ class FocusLifecycleTest { 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(), root.resolve("Research").toFile()); + store = new FocusStore(root.resolve("RPCharacters/data/focus").toFile()); } private RPCharacter character(String id) { @@ -103,34 +100,6 @@ void offlineRegenSettingControlsActivationWithoutChangingStoredTimestampOnRead() assertTrue(System.currentTimeMillis() - store.load("alice").getLastRegenMs() < 5000); } - @Test - void disabledOldCoreStillBlocksStartupBeforeAnyDataOrListeners() { - var core = mock(Plugin.class); - when(core.getResource("plugin.yml")).thenAnswer(invocation -> descriptor("name: TFMCCore\n")); - var server = mock(Server.class); - var manager = mock(PluginManager.class); - when(plugin.getServer()).thenReturn(server); - when(server.getPluginManager()).thenReturn(manager); - when(manager.getPlugin("TFMCCore")).thenReturn(core); - var module = new FocusModule(plugin); - assertFalse(module.start()); - assertFalse(assertDoesNotThrow(module::reloadConfig)); - assertNull(module.getService()); - verify(core, never()).isEnabled(); - verify(manager, never()).registerEvents(any(), any()); - assertFalse(Files.exists(root.resolve("RPCharacters"))); - } - - @Test - void markerAllowsHandoffAndAbsentCoreAllowsStandaloneOwner() { - var core = mock(Plugin.class); - when(core.getResource("plugin.yml")).thenReturn(descriptor("feature-owners:\n focus: RPCharacters\n")); - assertTrue(FocusOwnership.canStart(core, plugin.getLogger())); - assertTrue(FocusOwnership.canStart(null, plugin.getLogger())); - when(core.getResource("plugin.yml")).thenReturn(descriptor("feature-owners: [broken")); - assertFalse(FocusOwnership.canStart(core, plugin.getLogger())); - } - @Test void startupReloadAndShutdownHaveOneTimerAndSaveBalance() throws Exception { var server = mock(Server.class); @@ -168,7 +137,4 @@ void startupReloadAndShutdownHaveOneTimerAndSaveBalance() throws Exception { } } - private ByteArrayInputStream descriptor(String text) { - return new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8)); - } } diff --git a/src/test/java/net/tfminecraft/rpcharacters/focus/FocusMigrationTest.java b/src/test/java/net/tfminecraft/rpcharacters/focus/FocusMigrationTest.java deleted file mode 100644 index 67fe287..0000000 --- a/src/test/java/net/tfminecraft/rpcharacters/focus/FocusMigrationTest.java +++ /dev/null @@ -1,108 +0,0 @@ -package net.tfminecraft.rpcharacters.focus; - -import static org.junit.jupiter.api.Assertions.*; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -class FocusMigrationTest { - @TempDir Path root; - - @Test - void copiesLegacyBytesAndNeverOverwritesEitherOwnerOnRetry() throws Exception { - Path legacy = root.resolve("TFMCCore"); - Path owner = root.resolve("RPCharacters"); - Files.createDirectories(legacy.resolve("data/focus")); - String record = "{\"characterId\":\"alice\",\"ownerUuid\":\"account\",\"points\":42,\"lastRegenMs\":1234}"; - Files.writeString(legacy.resolve("focus.yml"), "max: 190\nbase_per_hour: 13\n"); - Files.writeString(legacy.resolve("data/focus/alice.json"), record); - FocusMigration.copyLegacy(legacy, owner); - assertEquals(record, Files.readString(owner.resolve("data/focus/alice.json"))); - assertEquals(Files.readString(legacy.resolve("focus.yml")), Files.readString(owner.resolve("focus.yml"))); - var store = new FocusStore(owner.resolve("data/focus").toFile()); - FocusData data = store.load("alice"); - assertEquals(42, data.getPoints()); - assertEquals(1234, data.getLastRegenMs()); - data.setPoints(17); - store.save(data); - Files.writeString(owner.resolve("focus.yml"), "max: 180\n"); - FocusMigration.copyLegacy(legacy, owner); - assertEquals(17, store.load("alice").getPoints()); - assertEquals("max: 180\n", Files.readString(owner.resolve("focus.yml"))); - assertEquals(record, Files.readString(legacy.resolve("data/focus/alice.json"))); - } - - @Test - void partialMigrationCanRetryAfterFilesystemFailure() throws Exception { - Path legacy = root.resolve("TFMCCore"); - Path owner = root.resolve("RPCharacters"); - Files.createDirectories(legacy.resolve("data/focus")); - Files.writeString(legacy.resolve("focus.yml"), "max: 170\n"); - Files.writeString(legacy.resolve("data/focus/alice.json"), "{\"points\":7,\"lastRegenMs\":100}"); - Files.createDirectories(owner); - Files.writeString(owner.resolve("data"), "obstruction"); - assertThrows(IOException.class, () -> FocusMigration.copyLegacy(legacy, owner)); - assertEquals("max: 170\n", Files.readString(owner.resolve("focus.yml"))); - assertTrue(Files.isRegularFile(legacy.resolve("data/focus/alice.json"))); - Files.delete(owner.resolve("data")); - FocusMigration.copyLegacy(legacy, owner); - assertEquals(7, new FocusStore(owner.resolve("data/focus").toFile()).load("alice").getPoints()); - } - - @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 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)); - } - } - - @Test - void legacyResearchImportPreservesPointsTimestampAndSource() throws Exception { - Path research = root.resolve("research"); - Files.createDirectories(research); - Path old = research.resolve("account.json"); - String original = "{\"mental_points\":23,\"last_regen_ms\":7000}"; - Files.writeString(old, original); - var store = new FocusStore(root.resolve("focus").toFile(), research.toFile()); - FocusData data = store.migrateFromResearch("alice", "account"); - assertEquals("alice", data.getCharacterId()); - assertEquals("account", data.getOwnerUuid()); - assertEquals(23, data.getPoints()); - assertEquals(7000, data.getLastRegenMs()); - assertEquals(original, Files.readString(old)); - Files.writeString(old, "{invalid"); - assertThrows(IllegalStateException.class, () -> store.migrateFromResearch("alice", "account")); - assertNull(store.load("alice")); - } -} 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..6efc91b --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/focus/FocusStoreTest.java @@ -0,0 +1,66 @@ +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 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)); + } + } + +} From a616f0f38f310056051875ff713fae80f8d41685 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:29:16 +0000 Subject: [PATCH 4/4] fix: limit focus access to the active character --- .../rpcharacters/focus/FocusService.java | 14 ++- .../rpcharacters/focus/FocusStore.java | 3 + .../focus/FocusLifecycleTest.java | 102 +++++++++++++++++- .../rpcharacters/focus/FocusStoreTest.java | 19 ++++ 4 files changed, 130 insertions(+), 8 deletions(-) diff --git a/src/main/java/net/tfminecraft/rpcharacters/focus/FocusService.java b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusService.java index 82bee31..f75b08c 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/focus/FocusService.java +++ b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusService.java @@ -132,7 +132,7 @@ public void deactivate(Player player) { public void saveAllOnline() { for (Player player : Bukkit.getOnlinePlayers()) { - FocusData data = loaded.get(player.getUniqueId()); + FocusData data = dataFor(player); if (data != null) { saveSafely(data); } @@ -150,7 +150,15 @@ private boolean saveSafely(FocusData data) { } private FocusData dataFor(Player player) { - return player == null ? null : loaded.get(player.getUniqueId()); + 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) { @@ -167,7 +175,7 @@ private void tickRegen() { long now = System.currentTimeMillis(); long intervalMs = Math.max(1L, FocusConfig.regenIntervalTicks) * 50L; for (Player player : Bukkit.getOnlinePlayers()) { - FocusData data = loaded.get(player.getUniqueId()); + FocusData data = dataFor(player); if (data == null) { continue; } diff --git a/src/main/java/net/tfminecraft/rpcharacters/focus/FocusStore.java b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusStore.java index f05ee26..7ed7ee2 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/focus/FocusStore.java +++ b/src/main/java/net/tfminecraft/rpcharacters/focus/FocusStore.java @@ -79,6 +79,9 @@ private static IllegalStateException failure(String operation, File file, Except } 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/test/java/net/tfminecraft/rpcharacters/focus/FocusLifecycleTest.java b/src/test/java/net/tfminecraft/rpcharacters/focus/FocusLifecycleTest.java index 569c6a4..8015250 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/focus/FocusLifecycleTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/focus/FocusLifecycleTest.java @@ -7,6 +7,7 @@ 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; @@ -18,9 +19,13 @@ 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; @@ -29,6 +34,8 @@ class FocusLifecycleTest { JavaPlugin plugin; Player player; FocusStore store; + MockedStatic characters; + final AtomicReference active = new AtomicReference<>(); @BeforeEach void setup() { FocusConfig.max = 150; @@ -42,8 +49,12 @@ class FocusLifecycleTest { 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); @@ -56,8 +67,10 @@ void switchAndQuitPersistIndependentCharacterBalances() { 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)); @@ -65,6 +78,7 @@ void switchAndQuitPersistIndependentCharacterBalances() { 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)); } @@ -72,10 +86,12 @@ void switchAndQuitPersistIndependentCharacterBalances() { @Test void failedActivationCannotReusePreviousCharacterOrOverwriteCorruptData() throws Exception { var service = new FocusService(plugin, store); - service.activate(player, character("alice")); + active.set(character("alice")); + service.activate(player, active.get()); Path bad = root.resolve("RPCharacters/data/focus/bob.json"); Files.writeString(bad, "broken"); - service.activate(player, character("bob")); + active.set(character("bob")); + service.activate(player, active.get()); assertEquals(0, service.getPoints(player)); assertFalse(service.trySpend(player, 1)); service.deactivate(player); @@ -91,15 +107,90 @@ void offlineRegenSettingControlsActivationWithoutChangingStoredTimestampOnRead() store.save(data); var service = new FocusService(plugin, store); FocusConfig.offlineRegen = true; - service.activate(player, character("alice")); + active.set(character("alice")); + service.activate(player, active.get()); assertEquals(40, service.getPoints(player)); FocusConfig.offlineRegen = false; - service.activate(player, character("alice")); + 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); @@ -120,7 +211,8 @@ void startupReloadAndShutdownHaveOneTimerAndSaveBalance() throws Exception { assertTrue(module.start()); assertTrue(module.start()); FocusService service = module.getService(); - service.activate(player, character("alice")); + 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()); diff --git a/src/test/java/net/tfminecraft/rpcharacters/focus/FocusStoreTest.java b/src/test/java/net/tfminecraft/rpcharacters/focus/FocusStoreTest.java index 6efc91b..a2da17c 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/focus/FocusStoreTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/focus/FocusStoreTest.java @@ -43,6 +43,25 @@ void corruptExistingRecordIsAnErrorNotAbsentOrFreshState() throws Exception { 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");