From b0bb5431175eb300fe1358b89947a4645454c454 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:19:38 +0000 Subject: [PATCH 1/5] Keep a Codex journal per character Load a character's discoveries from characters/.yml when its session starts and save them when it ends, so each character discovers places and lore and earns their rewards for itself. Between characters nothing can be discovered. Each account's old players/.yml goes to its RPCharacters heir once heirs are known, or to its most-played living character if it has no reviewed heir; the old file moves to players-migrated/. Other characters start empty. Drop MySQL storage, which neither server used. Saves now run on the main thread. Co-Authored-By: Claude Opus 5.5 --- README.md | 13 +- pom.xml | 97 ++++- .../java/net/tfminecraft/codex/Codex.java | 25 +- .../net/tfminecraft/codex/api/CodexAPI.java | 2 +- .../codex/character/CharacterJournals.java | 161 +++++++++ .../codex/character/CharacterStore.java | 156 ++++++++ .../tfminecraft/codex/character/Heirs.java | 45 +++ .../codex/config/ConfigsManager.java | 3 +- .../codex/config/MainConfigManager.java | 13 - .../codex/config/PlayersConfigManager.java | 82 +---- .../codex/database/HikariConnection.java | 57 --- .../codex/database/MySQLConnection.java | 321 ----------------- .../codex/database/PlayerCallback.java | 9 - .../codex/listeners/PlayerListener.java | 13 - .../codex/managers/DiscoveryManager.java | 3 +- .../codex/managers/PlayerDataManager.java | 176 +++------ .../codex/model/data/PlayerData.java | 37 +- .../codex/tasks/PlayerDataSaveTask.java | 2 +- src/main/resources/config.yml | 15 - src/main/resources/plugin.yml | 3 +- .../character/CharacterJournalsTest.java | 334 ++++++++++++++++++ .../codex/character/CharacterStoreTest.java | 165 +++++++++ .../codex/character/HeirsTest.java | 79 +++++ .../codex/model/data/PlayerDataTest.java | 32 ++ 24 files changed, 1162 insertions(+), 681 deletions(-) create mode 100644 src/main/java/net/tfminecraft/codex/character/CharacterJournals.java create mode 100644 src/main/java/net/tfminecraft/codex/character/CharacterStore.java create mode 100644 src/main/java/net/tfminecraft/codex/character/Heirs.java delete mode 100644 src/main/java/net/tfminecraft/codex/database/HikariConnection.java delete mode 100644 src/main/java/net/tfminecraft/codex/database/MySQLConnection.java delete mode 100644 src/main/java/net/tfminecraft/codex/database/PlayerCallback.java create mode 100644 src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java create mode 100644 src/test/java/net/tfminecraft/codex/character/CharacterStoreTest.java create mode 100644 src/test/java/net/tfminecraft/codex/character/HeirsTest.java create mode 100644 src/test/java/net/tfminecraft/codex/model/data/PlayerDataTest.java diff --git a/README.md b/README.md index 2c3bac2..3b67da0 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,13 @@ Codex gives players a journal of discoveries: places they enter, creatures they kill and lore that commands unlock. `/codex` opens the journal, and each entry -shows its text once it has been found. This copy tracks upstream 2.12.1 and builds -for Paper 1.21.10. It has no update checker or bStats reporting. +shows its text once it has been found. + +Each roleplay character keeps its own journal, so a new character discovers the +world afresh and earns the discovery rewards again. What an account found before +this goes to the character that inherited its belongings. This copy tracks upstream +2.12.1, builds for Paper 1.21.10 and needs TLibs 2.6.1 with RPCharacters. It stores +journals in files only, and has no update checker or bStats reporting. ## Documentation @@ -16,7 +21,9 @@ discoveries, actions and placeholders. ## Tests -With Java 21, run `mvn clean verify`. +With Java 21 and the pinned plugin dependencies installed, run `mvn clean verify`. +JUnit and Mockito cover the per-character journals, which must keep 100% line +coverage. Upstream Codex has no tests. ## License diff --git a/pom.xml b/pom.xml index 6477cc8..b0fe501 100644 --- a/pom.xml +++ b/pom.xml @@ -15,6 +15,8 @@ 21 UTF-8 + + 2.6.1 @@ -36,12 +38,6 @@ 2.11.6 provided - - com.zaxxer - HikariCP - 4.0.3 - compile - com.sk89q.worldguard worldguard-bukkit @@ -72,6 +68,31 @@ 6.0.0.1 provided + + me.plugins + tlibs + ${tlibs.version} + provided + + + + * + * + + + + + org.junit.jupiter + junit-jupiter + 5.14.0 + test + + + org.mockito + mockito-core + 5.20.0 + test + @@ -139,25 +160,65 @@ org.apache.maven.plugins - maven-shade-plugin - 3.6.2 + maven-dependency-plugin + 3.7.0 + + + + properties + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + @{argLine} -javaagent:${org.mockito:mockito-core:jar} -Xshare:off + true + + + + org.jacoco + jacoco-maven-plugin + 0.8.15 - package - shade + prepare-agent + + + + coverage-report + verify + + report + + + + coverage-check + verify + + check - false - - - com.zaxxer.hikari - net.tfminecraft.codex.libs.hikaricp + + + + PACKAGE - com.zaxxer.hikari.** + net.tfminecraft.codex.character - - + + + LINE + MISSEDCOUNT + 0 + + + + diff --git a/src/main/java/net/tfminecraft/codex/Codex.java b/src/main/java/net/tfminecraft/codex/Codex.java index d516d12..f81342f 100644 --- a/src/main/java/net/tfminecraft/codex/Codex.java +++ b/src/main/java/net/tfminecraft/codex/Codex.java @@ -1,10 +1,11 @@ package net.tfminecraft.codex; import net.tfminecraft.codex.api.CodexAPI; +import net.tfminecraft.codex.character.CharacterJournals; +import net.tfminecraft.codex.character.CharacterStore; import net.tfminecraft.codex.api.ExpansionCodex; import net.tfminecraft.codex.commands.MainCommand; import net.tfminecraft.codex.config.ConfigsManager; -import net.tfminecraft.codex.database.MySQLConnection; import net.tfminecraft.codex.listeners.InventoryListener; import net.tfminecraft.codex.listeners.PlayerListener; import net.tfminecraft.codex.listeners.dependencies.EliteMobsListener; @@ -37,8 +38,8 @@ public class Codex extends JavaPlugin { private DiscoveryManager discoveryManager; private DependencyManager dependencyManager; private PlayerDataManager playerDataManager; + private CharacterJournals characterJournals; - private MySQLConnection mySQLConnection; private PlayerDataSaveTask playerDataSaveTask; private VerifyManager verifyManager; @@ -61,8 +62,10 @@ public void onEnable(){ return; } + characterJournals = new CharacterJournals(playerDataManager, new CharacterStore(getDataFolder().toPath()), getLogger()); registerEvents(); registerCommands(); + characterJournals.start(this); reloadPlayerDataSaveTask(); @@ -74,16 +77,13 @@ public void onEnable(){ this.verifyManager = new VerifyManager(this); this.verifyManager.verify(); - if(configsManager.getMainConfigManager().isMySQL()){ - mySQLConnection = new MySQLConnection(this); - mySQLConnection.setupMySql(); - } - Bukkit.getConsoleSender().sendMessage(prefix+MessagesManager.getLegacyColoredMessage("&eHas been enabled! &fVersion: "+version)); } public void onDisable(){ - configsManager.getPlayersConfigManager().saveConfigs(); + if(characterJournals != null){ + characterJournals.stop(); + } Bukkit.getConsoleSender().sendMessage(prefix+MessagesManager.getLegacyColoredMessage("&eHas been disabled! &fVersion: "+version)); } @@ -95,6 +95,7 @@ public void registerEvents(){ PluginManager pm = getServer().getPluginManager(); pm.registerEvents(new InventoryListener(this), this); pm.registerEvents(new PlayerListener(this), this); + pm.registerEvents(characterJournals, this); if(dependencyManager.getWorldGuardManager() != null){ pm.registerEvents(new WorldGuardListener(this), this); } @@ -210,12 +211,12 @@ public DependencyManager getDependencyManager() { return dependencyManager; } - public PlayerDataManager getPlayerDataManager() { - return playerDataManager; + public CharacterJournals getCharacterJournals() { + return characterJournals; } - public MySQLConnection getMySQLConnection() { - return mySQLConnection; + public PlayerDataManager getPlayerDataManager() { + return playerDataManager; } public CategoryManager getCategoryManager() { diff --git a/src/main/java/net/tfminecraft/codex/api/CodexAPI.java b/src/main/java/net/tfminecraft/codex/api/CodexAPI.java index d96a9c7..cf6ab91 100644 --- a/src/main/java/net/tfminecraft/codex/api/CodexAPI.java +++ b/src/main/java/net/tfminecraft/codex/api/CodexAPI.java @@ -24,7 +24,7 @@ public static boolean hasCompletedCategory(String playerName, String categoryNam } public static PlayerData getPlayerData(Player player){ - return plugin.getPlayerDataManager().getPlayer(player,false); + return plugin.getPlayerDataManager().getPlayer(player); } public static PlayerData getPlayerData(String playerName){ diff --git a/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java b/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java new file mode 100644 index 0000000..b06e7b2 --- /dev/null +++ b/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java @@ -0,0 +1,161 @@ +package net.tfminecraft.codex.character; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitTask; + +import net.tfminecraft.codex.managers.PlayerDataManager; +import net.tfminecraft.codex.model.data.PlayerData; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSessionEndEvent; +import net.tfminecraft.tlibs.character.CharacterSessionStartEvent; +import net.tfminecraft.tlibs.character.Characters; + +/** + * Gives each character its own journal: loaded when a character session starts and saved when it + * ends. Between characters a player has no journal, so nothing can be discovered. Main thread only. + */ +public final class CharacterJournals implements Listener { + private final PlayerDataManager players; + private final CharacterStore store; + private final Logger logger; + private BukkitTask sweep; + + public CharacterJournals(PlayerDataManager players, CharacterStore store, Logger logger) { + this.players = players; + this.store = store; + this.logger = logger; + } + + /** Opens journals for characters already in play and moves old account files once heirs are known. */ + public void start(Plugin plugin) { + for (Player player : Bukkit.getOnlinePlayers()) { + Characters.currentSession(player).ifPresent(session -> open(player, session.ref())); + } + sweep = Bukkit.getScheduler().runTaskTimer(plugin, () -> { + if (Characters.legacyHeirsReady()) { + sweep.cancel(); + migrateAll(); + } + }, 1L, 20L); + } + + public void stop() { + if (sweep != null) { + sweep.cancel(); + } + saveAll(); + } + + @EventHandler(priority = EventPriority.LOWEST) + public void onStart(CharacterSessionStartEvent event) { + open(event.getPlayer(), event.getSession().ref()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onEnd(CharacterSessionEndEvent event) { + close(event.getPlayer(), event.getSession().ref().characterId()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onQuit(PlayerQuitEvent event) { + close(event.getPlayer(), null); + } + + void open(Player player, CharacterRef ref) { + close(player, null); + migrate(ref.ownerId()); + try { + players.addPlayer(store.load(ref.ownerId(), ref.characterId(), player.getName())); + } catch (IOException ex) { + logger.log(Level.SEVERE, "Codex for character " + ref.characterId() + " of " + player.getName() + + " could not be read; nothing can be discovered until it is fixed", ex); + } + } + + /** Saves and unloads the player's journal; with a character ID, only that character's. */ + void close(Player player, UUID characterId) { + PlayerData data = players.getPlayerByUUID(player.getUniqueId()); + if (data == null || characterId != null && !characterId.equals(data.getCharacterId())) { + return; + } + save(data); + players.removePlayer(data); + } + + public void saveAll() { + for (PlayerData data : new ArrayList<>(players.getPlayers().values())) { + save(data); + } + } + + private void save(PlayerData data) { + if (!data.isModified()) { + return; + } + try { + store.save(data); + } catch (IOException ex) { + logger.log(Level.SEVERE, "Could not save Codex for character " + data.getCharacterId(), ex); + } + } + + void migrateAll() { + List owners; + try { + owners = store.legacyOwners(); + } catch (IOException ex) { + logger.log(Level.SEVERE, "Could not list pre-character Codex files", ex); + return; + } + int moved = 0; + for (UUID owner : owners) { + if (migrate(owner)) { + moved++; + } + } + if (!owners.isEmpty()) { + logger.info("Moved " + moved + " of " + owners.size() + " account Codex files to characters; " + + "the rest move when their account has a character"); + } + } + + /** Merges the account's old discoveries into its heir, then sets the old file aside. */ + boolean migrate(UUID owner) { + if (!store.hasLegacy(owner)) { + return false; + } + Optional heir = Heirs.of(owner); + if (heir.isEmpty()) { + return false; + } + try { + CharacterStore.LegacyJournal legacy = store.readLegacy(owner); + PlayerData target = players.getPlayerByUUID(owner); + if (target == null || !heir.get().equals(target.getCharacterId())) { + target = store.load(owner, heir.get(), legacy.name()); + } + int added = target.absorb(legacy.categories()); + store.save(target); + store.retireLegacy(owner); + logger.info("Moved " + added + " Codex discoveries of account " + owner + " to character " + heir.get()); + return true; + } catch (IOException ex) { + logger.log(Level.SEVERE, "Could not move the Codex of account " + owner + " to its heir", ex); + return false; + } + } +} diff --git a/src/main/java/net/tfminecraft/codex/character/CharacterStore.java b/src/main/java/net/tfminecraft/codex/character/CharacterStore.java new file mode 100644 index 0000000..4cc558e --- /dev/null +++ b/src/main/java/net/tfminecraft/codex/character/CharacterStore.java @@ -0,0 +1,156 @@ +package net.tfminecraft.codex.character; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; + +import net.tfminecraft.codex.model.data.PlayerData; +import net.tfminecraft.codex.model.data.PlayerDataCategory; +import net.tfminecraft.codex.model.data.PlayerDataDiscovery; + +/** + * Discovery files. Each character has {@code characters/.yml}; {@code players/.yml} + * holds what an account found before characters had their own, until it moves to the account's heir + * and the old file goes to {@code players-migrated/}. Main thread only. + */ +public final class CharacterStore { + public static final String CHARACTERS = "characters"; + static final String LEGACY = "players"; + static final String MIGRATED = "players-migrated"; + + private final Path characters; + private final Path legacy; + private final Path migrated; + + public CharacterStore(Path dataFolder) { + this.characters = dataFolder.resolve(CHARACTERS); + this.legacy = dataFolder.resolve(LEGACY); + this.migrated = dataFolder.resolve(MIGRATED); + } + + /** What an account found before characters, with the account name it was saved under. */ + public record LegacyJournal(String name, List categories) { + } + + public Path characterFile(UUID characterId) { + return characters.resolve(characterId + ".yml"); + } + + /** The character's journal. A new journal is marked modified, so every played character gets a file. */ + public PlayerData load(UUID owner, UUID characterId, String name) throws IOException { + PlayerData data = new PlayerData(owner, characterId, name); + Path file = characterFile(characterId); + if (Files.exists(file)) { + data.setCategories(categories(read(file))); + } else { + data.setModified(true); + } + return data; + } + + /** Writes the journal through a temporary file, so a failed write leaves the old file whole. */ + public void save(PlayerData data) throws IOException { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("owner", data.getUuid().toString()); + yaml.set("name", data.getName()); + for (PlayerDataCategory category : data.getCategories()) { + String path = "categories." + category.getName(); + yaml.set(path + ".completed", category.isCompleted()); + yaml.set(path + ".discoveries", category.getDiscoveriesToStringList()); + } + Files.createDirectories(characters); + Path target = characterFile(data.getCharacterId()); + Path temporary = characters.resolve(data.getCharacterId() + ".yml.tmp"); + Files.writeString(temporary, yaml.saveToString(), StandardCharsets.UTF_8); + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + data.setModified(false); + } + + /** Accounts with a pre-character file still waiting to move. Files not named by a UUID are left alone. */ + public List legacyOwners() throws IOException { + List owners = new ArrayList<>(); + if (!Files.isDirectory(legacy)) { + return owners; + } + try (DirectoryStream files = Files.newDirectoryStream(legacy, "*.yml")) { + for (Path file : files) { + String stem = file.getFileName().toString(); + try { + owners.add(UUID.fromString(stem.substring(0, stem.length() - ".yml".length()))); + } catch (IllegalArgumentException notAccount) { + // Not an account file. + } + } + } + return owners; + } + + public boolean hasLegacy(UUID owner) { + return Files.isRegularFile(legacyFile(owner)); + } + + public LegacyJournal readLegacy(UUID owner) throws IOException { + YamlConfiguration yaml = read(legacyFile(owner)); + return new LegacyJournal(yaml.getString("name"), categories(yaml)); + } + + /** Moves the account's old file aside once its discoveries belong to a character. */ + public void retireLegacy(UUID owner) throws IOException { + Files.createDirectories(migrated); + Files.move(legacyFile(owner), migrated.resolve(owner + ".yml"), StandardCopyOption.REPLACE_EXISTING); + } + + private Path legacyFile(UUID owner) { + return legacy.resolve(owner + ".yml"); + } + + private static YamlConfiguration read(Path file) throws IOException { + YamlConfiguration yaml = new YamlConfiguration(); + try { + yaml.loadFromString(Files.readString(file, StandardCharsets.UTF_8)); + } catch (InvalidConfigurationException ex) { + throw new IOException("Unreadable discovery file " + file, ex); + } + return yaml; + } + + /** Discoveries are saved as {@code id;date;millis}; older files may lack the millis. */ + private static List categories(YamlConfiguration yaml) { + List categories = new ArrayList<>(); + ConfigurationSection section = yaml.getConfigurationSection("categories"); + if (section == null) { + return categories; + } + for (String name : section.getKeys(false)) { + ArrayList discoveries = new ArrayList<>(); + for (String entry : section.getStringList(name + ".discoveries")) { + String[] parts = entry.split(";", -1); + if (parts[0].isBlank()) { + continue; + } + String date = parts.length > 1 ? parts[1] : ""; + discoveries.add(new PlayerDataDiscovery(parts[0], date, parts.length > 2 ? millis(parts[2]) : 0)); + } + categories.add(new PlayerDataCategory(name, section.getBoolean(name + ".completed"), discoveries)); + } + return categories; + } + + private static long millis(String text) { + try { + return Long.parseLong(text.trim()); + } catch (NumberFormatException ex) { + return 0; + } + } +} diff --git a/src/main/java/net/tfminecraft/codex/character/Heirs.java b/src/main/java/net/tfminecraft/codex/character/Heirs.java new file mode 100644 index 0000000..e796b6b --- /dev/null +++ b/src/main/java/net/tfminecraft/codex/character/Heirs.java @@ -0,0 +1,45 @@ +package net.tfminecraft.codex.character; + +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.Characters; + +/** Which character inherits an account's pre-character discoveries. Main thread only. */ +final class Heirs { + private static final Comparator MOST_PLAYED = Comparator + .comparingLong(CharacterInfo::playtimeSeconds) + .thenComparing(info -> info.lastPlayedAt() == null ? Instant.MIN : info.lastPlayedAt()); + + private Heirs() { + } + + /** Empty until RPCharacters knows its heirs, and for accounts with no character yet. */ + static Optional of(UUID owner) { + if (!Characters.legacyHeirsReady()) { + return Optional.empty(); + } + return choose(Characters.legacyHeir(owner), Characters.characters(owner)); + } + + /** + * The reviewed heir RPCharacters gives money and bodies to. Accounts that joined after that review + * have none, so their most-played living character inherits, else their most-played character. + */ + static Optional choose(Optional reviewed, Optional> characters) { + if (reviewed.isPresent()) { + return Optional.of(reviewed.get().characterId()); + } + List all = characters.orElse(List.of()); + Optional heir = all.stream().filter(CharacterInfo::alive).max(MOST_PLAYED); + if (heir.isEmpty()) { + heir = all.stream().max(MOST_PLAYED); + } + return heir.map(info -> info.ref().characterId()); + } +} diff --git a/src/main/java/net/tfminecraft/codex/config/ConfigsManager.java b/src/main/java/net/tfminecraft/codex/config/ConfigsManager.java index 175de2f..f2b90e5 100644 --- a/src/main/java/net/tfminecraft/codex/config/ConfigsManager.java +++ b/src/main/java/net/tfminecraft/codex/config/ConfigsManager.java @@ -1,5 +1,6 @@ package net.tfminecraft.codex.config; +import net.tfminecraft.codex.character.CharacterStore; import net.tfminecraft.codex.Codex; @@ -16,7 +17,7 @@ public ConfigsManager(Codex plugin) { this.plugin = plugin; this.mainConfigManager = new MainConfigManager(plugin); this.categoriesConfigManager = new CategoriesConfigManager(plugin,"categories"); - this.playersConfigManager = new PlayersConfigManager(plugin,"players"); + this.playersConfigManager = new PlayersConfigManager(plugin,CharacterStore.CHARACTERS); this.messagesConfigManager = new MessagesConfigManager(plugin); this.inventoryConfigManager = new InventoryConfigManager(plugin); } diff --git a/src/main/java/net/tfminecraft/codex/config/MainConfigManager.java b/src/main/java/net/tfminecraft/codex/config/MainConfigManager.java index b8b715f..b3d3c0f 100644 --- a/src/main/java/net/tfminecraft/codex/config/MainConfigManager.java +++ b/src/main/java/net/tfminecraft/codex/config/MainConfigManager.java @@ -13,7 +13,6 @@ public class MainConfigManager { private Codex plugin; private CommonConfig configFile; - private boolean isMySQL; private String discoveriesDateFormat; private int playerDataSave; private String progressBarPlaceholderFillSymbol; @@ -31,7 +30,6 @@ public MainConfigManager(Codex plugin){ public void configure() { FileConfiguration config = configFile.getConfig(); - isMySQL = config.getBoolean("mysql_database.enabled"); discoveriesDateFormat = config.getString("discoveries_date_format"); playerDataSave = config.getInt("player_data_save"); progressBarPlaceholderFillSymbol = config.getString("progress_bar_placeholder.filled_symbol"); @@ -57,13 +55,6 @@ public void checkUpdate(){ getConfig().set("use_minimessage",false); configFile.saveConfig(); } - if(!text.contains("verifyServerCertificate:")){ - getConfig().set("mysql_database.pool.connectionTimeout",5000); - getConfig().set("mysql_database.advanced.verifyServerCertificate",false); - getConfig().set("mysql_database.advanced.useSSL",true); - getConfig().set("mysql_database.advanced.allowPublicKeyRetrieval",true); - configFile.saveConfig(); - } }catch(IOException e){ e.printStackTrace(); } @@ -77,10 +68,6 @@ public FileConfiguration getConfig(){ return configFile.getConfig(); } - public boolean isMySQL() { - return isMySQL; - } - public String getDiscoveriesDateFormat() { return discoveriesDateFormat; } diff --git a/src/main/java/net/tfminecraft/codex/config/PlayersConfigManager.java b/src/main/java/net/tfminecraft/codex/config/PlayersConfigManager.java index b551d9f..cd2c3ad 100644 --- a/src/main/java/net/tfminecraft/codex/config/PlayersConfigManager.java +++ b/src/main/java/net/tfminecraft/codex/config/PlayersConfigManager.java @@ -1,19 +1,14 @@ package net.tfminecraft.codex.config; import net.tfminecraft.codex.Codex; -import net.tfminecraft.codex.model.data.PlayerData; -import net.tfminecraft.codex.model.data.PlayerDataCategory; -import net.tfminecraft.codex.model.data.PlayerDataDiscovery; -import net.tfminecraft.codex.model.internal.GenericCallback; import org.bukkit.configuration.file.FileConfiguration; -import org.bukkit.scheduler.BukkitRunnable; import java.io.File; import java.util.*; +/** The per-character discovery files in {@code characters/}; see {@link net.tfminecraft.codex.character.CharacterStore}. */ public class PlayersConfigManager extends DataFolderConfigManager { - public PlayersConfigManager(Codex plugin, String folderName) { super(plugin, folderName); } @@ -23,82 +18,9 @@ public void loadConfigs(){ // No use for player config } - public void loadConfig(UUID uuid, GenericCallback callback){ - new BukkitRunnable(){ - @Override - public void run() { - PlayerData playerData = null; - CommonConfig playerConfig = getConfigFile(uuid+".yml",false); - if(playerConfig != null){ - // If config exists - FileConfiguration config = playerConfig.getConfig(); - String name = config.getString("name"); - ArrayList playerDataCategories = new ArrayList<>(); - if(config.contains("categories")){ - for(String key : config.getConfigurationSection("categories").getKeys(false)){ - List discoveriesStringList = config.getStringList("categories."+key+".discoveries"); - boolean completed = config.getBoolean("categories."+key+".completed"); - - ArrayList discoveries = new ArrayList<>(); - for(String d : discoveriesStringList){ - String[] sep = d.split(";"); - long millisActionsExecuted = 0; - if(sep.length == 3){ - millisActionsExecuted = Long.parseLong(sep[2]); - } - discoveries.add(new PlayerDataDiscovery(sep[0],sep[1],millisActionsExecuted)); - } - playerDataCategories.add(new PlayerDataCategory(key,completed,discoveries)); - } - } - - playerData = new PlayerData(uuid,name); - playerData.setCategories(playerDataCategories); - } - - PlayerData finalPlayer = playerData; - - new BukkitRunnable(){ - @Override - public void run() { - callback.onDone(finalPlayer); - } - }.runTask(plugin); - } - }.runTaskAsynchronously(plugin); - } - - public void saveConfig(PlayerData playerData){ - String playerName = playerData.getName(); - CommonConfig playerConfig = getConfigFile(playerData.getUuid()+".yml",true); - FileConfiguration config = playerConfig.getConfig(); - - config.set("name", playerName); - config.set("categories",null); - List categories = playerData.getCategories(); - for(PlayerDataCategory category : categories){ - String path = "categories."+category.getName(); - config.set(path+".completed",category.isCompleted()); - config.set(path+".discoveries",category.getDiscoveriesToStringList()); - } - - playerConfig.saveConfig(); - } - @Override public void saveConfigs(){ - Map players = plugin.getPlayerDataManager().getPlayers(); - boolean isMySQL = plugin.getConfigsManager().getMainConfigManager().isMySQL(); - if(!isMySQL){ - for(Map.Entry entry : players.entrySet()) { - PlayerData playerData = entry.getValue(); - if(playerData.isModified()){ - saveConfig(playerData); - } - playerData.setModified(false); - } - } - + plugin.getCharacterJournals().saveAll(); } public void resetDataForAllPlayers(String categoryName,String discoveryName){ diff --git a/src/main/java/net/tfminecraft/codex/database/HikariConnection.java b/src/main/java/net/tfminecraft/codex/database/HikariConnection.java deleted file mode 100644 index 42f4f1e..0000000 --- a/src/main/java/net/tfminecraft/codex/database/HikariConnection.java +++ /dev/null @@ -1,57 +0,0 @@ -package net.tfminecraft.codex.database; - -import com.zaxxer.hikari.HikariConfig; -import com.zaxxer.hikari.HikariDataSource; -import org.bukkit.configuration.file.FileConfiguration; - -public class HikariConnection { - - private HikariDataSource hikari; - - public HikariConnection(FileConfiguration config) { - HikariConfig hikariConfig = new HikariConfig(); - String path = "mysql_database"; - - String host = config.getString(path+".host"); - int port = config.getInt(path+".port"); - String database = config.getString(path+".database"); - String username = config.getString(path+".username"); - String password = config.getString(path+".password"); - - hikariConfig.setJdbcUrl("jdbc:mysql://" + host + ":" + port + "/" + database); - hikariConfig.setUsername(username); - hikariConfig.setPassword(password); - - if(config.contains(path+".pool.connectionTimeout")){ - hikariConfig.setConnectionTimeout(config.getLong(path+".pool.connectionTimeout")); - } - if(config.contains(path+".pool.maximumPoolSize")){ - hikariConfig.setMaximumPoolSize(config.getInt(path+".pool.maximumPoolSize")); - } - if(config.contains(path+".pool.connectionTimeout")){ - hikariConfig.setKeepaliveTime(config.getLong(path+".pool.keepaliveTime")); - } - if(config.contains(path+".pool.idleTimeout")){ - hikariConfig.setIdleTimeout(config.getLong(path+".pool.idleTimeout")); - } - if(config.contains(path+".pool.maxLifetime")){ - hikariConfig.setMaxLifetime(config.getLong(path+".pool.maxLifetime")); - } - - for(String key : config.getConfigurationSection(path+".advanced").getKeys(false)){ - hikariConfig.addDataSourceProperty(key,config.get(path+".advanced."+key)); - } - - hikari = new HikariDataSource(hikariConfig); - } - - public HikariDataSource getHikari() { - return this.hikari; - } - - public void disable() { - if(hikari != null) { - hikari.close(); - } - } -} diff --git a/src/main/java/net/tfminecraft/codex/database/MySQLConnection.java b/src/main/java/net/tfminecraft/codex/database/MySQLConnection.java deleted file mode 100644 index 499cb91..0000000 --- a/src/main/java/net/tfminecraft/codex/database/MySQLConnection.java +++ /dev/null @@ -1,321 +0,0 @@ -package net.tfminecraft.codex.database; - -import net.tfminecraft.codex.Codex; -import net.tfminecraft.codex.managers.MessagesManager; -import net.tfminecraft.codex.model.data.PlayerData; -import net.tfminecraft.codex.model.data.PlayerDataCategory; -import net.tfminecraft.codex.model.data.PlayerDataDiscovery; -import org.bukkit.Bukkit; -import org.bukkit.configuration.file.FileConfiguration; -import org.bukkit.scheduler.BukkitRunnable; - -import java.sql.*; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Map; -import java.util.UUID; - -public class MySQLConnection { - - private Codex plugin; - private HikariConnection connection; - - public MySQLConnection(Codex plugin){ - this.plugin = plugin; - } - - public void setupMySql(){ - FileConfiguration config = plugin.getConfigsManager().getMainConfigManager().getConfig(); - try { - connection = new HikariConnection(config); - connection.getHikari().getConnection(); - createTables(); - Bukkit.getConsoleSender().sendMessage(MessagesManager.getLegacyColoredMessage(plugin.prefix+"&aSuccessfully connected to the Database.")); - }catch(Exception e) { - Bukkit.getConsoleSender().sendMessage(MessagesManager.getLegacyColoredMessage(plugin.prefix+"&cError while connecting to the Database.")); - } - } - - public Connection getConnection() { - try { - return connection.getHikari().getConnection(); - } catch (Exception e) { - e.printStackTrace(); - return null; - } - } - - public void createTables() { - try(Connection connection = getConnection()){ - PreparedStatement statement1 = connection.prepareStatement( - "CREATE TABLE IF NOT EXISTS codex_players" + - " (UUID varchar(36) NOT NULL, " + - " PLAYER_NAME varchar(50), " + - " PRIMARY KEY ( UUID ))" - ); - statement1.executeUpdate(); - PreparedStatement statement2 = connection.prepareStatement( - "CREATE TABLE IF NOT EXISTS codex_players_discoveries" + - " (ID int NOT NULL AUTO_INCREMENT, " + - " UUID varchar(36) NOT NULL, " + - " CATEGORY varchar(100), " + - " DISCOVERY varchar(100), " + - " DATE varchar(100), " + - " MILLIS_ACTIONS_EXECUTED bigint, " + - " PRIMARY KEY ( ID ), " + - " FOREIGN KEY (UUID) REFERENCES codex_players(UUID))"); - statement2.executeUpdate(); - PreparedStatement statement3 = connection.prepareStatement( - "CREATE TABLE IF NOT EXISTS codex_players_completed_categories" + - " (ID int NOT NULL AUTO_INCREMENT, " + - " UUID varchar(36) NOT NULL, " + - " CATEGORY varchar(100), " + - " PRIMARY KEY ( ID ), " + - " FOREIGN KEY (UUID) REFERENCES codex_players(UUID))"); - statement3.executeUpdate(); - - DatabaseMetaData dbm = connection.getMetaData(); - ResultSet rs = dbm.getColumns(null, null, "codex_players_discoveries", "MILLIS_ACTIONS_EXECUTED"); - if (!rs.next()) { - // Column doesn't exist. - PreparedStatement alterStatement = connection.prepareStatement( - "ALTER TABLE codex_players_discoveries ADD COLUMN MILLIS_ACTIONS_EXECUTED bigint"); - alterStatement.executeUpdate(); - alterStatement.close(); - } - } catch (SQLException e) { - e.printStackTrace(); - } - } - - public void getPlayer(String uuid,PlayerCallback callback){ - new BukkitRunnable(){ - @Override - public void run() { - PlayerData player = null; - try(Connection connection = getConnection()){ - PreparedStatement statement = connection.prepareStatement( - "SELECT codex_players.UUID, codex_players.PLAYER_NAME, " + - "(SELECT GROUP_CONCAT(cc.Category) FROM codex_players_completed_categories cc WHERE cc.UUID = codex_players.UUID) AS COMPLETED_CATEGORIES, " + - "codex_players_discoveries.CATEGORY AS DISCOVERY_CATEGORY, " + - "codex_players_discoveries.DISCOVERY, " + - "codex_players_discoveries.DATE, " + - "codex_players_discoveries.MILLIS_ACTIONS_EXECUTED " + - "FROM codex_players " + - "LEFT JOIN codex_players_discoveries ON codex_players.UUID = codex_players_discoveries.UUID " + - "WHERE codex_players.UUID = ?"); - - statement.setString(1, uuid); - ResultSet result = statement.executeQuery(); - - while(result.next()){ - UUID uuid = UUID.fromString(result.getString("UUID")); - String playerName = result.getString("PLAYER_NAME"); - String completedCategories = result.getString("COMPLETED_CATEGORIES"); - String discoveryCategoryName = result.getString("DISCOVERY_CATEGORY"); - String discoveryName = result.getString("DISCOVERY"); - String discoveryDate = result.getString("DATE"); - long discoveryMillisActionsExecuted = result.getLong("MILLIS_ACTIONS_EXECUTED"); - - if(player == null){ - player = new PlayerData(uuid,playerName); - } - - if(discoveryCategoryName != null){ - boolean hasCompletedCategory = completedCategories != null && completedCategories.contains(discoveryCategoryName); - PlayerDataCategory playerDataCategory = player.getCategory(discoveryCategoryName); - if(playerDataCategory == null){ - playerDataCategory = new PlayerDataCategory(discoveryCategoryName,hasCompletedCategory,new ArrayList<>()); - player.getCategories().add(playerDataCategory); - } - - playerDataCategory.getDiscoveries().add(new PlayerDataDiscovery(discoveryName,discoveryDate,discoveryMillisActionsExecuted)); - } - } - - PlayerData finalPlayer = player; - new BukkitRunnable(){ - @Override - public void run() { - callback.onDone(finalPlayer); - } - }.runTask(plugin); - } catch (SQLException e) { - e.printStackTrace(); - } - } - }.runTaskAsynchronously(plugin); - } - - public void createPlayer(PlayerData player){ - new BukkitRunnable(){ - @Override - public void run() { - try(Connection connection = getConnection()){ - PreparedStatement statement = connection.prepareStatement( - "INSERT INTO codex_players " + - "(UUID, PLAYER_NAME) VALUE (?,?)"); - - statement.setString(1, player.getUuid().toString()); - statement.setString(2, player.getName()); - statement.executeUpdate(); - } catch (SQLException e) { - e.printStackTrace(); - } - } - }.runTaskAsynchronously(plugin); - } - - public void updatePlayerName(PlayerData player){ - new BukkitRunnable(){ - @Override - public void run() { - try(Connection connection = getConnection()){ - PreparedStatement statement = connection.prepareStatement( - "UPDATE codex_players SET " + - "PLAYER_NAME=? WHERE UUID=?"); - - statement.setString(1, player.getName()); - statement.setString(2, player.getUuid().toString()); - statement.executeUpdate(); - } catch (SQLException e) { - e.printStackTrace(); - } - } - }.runTaskAsynchronously(plugin); - } - - public void addDiscovery(String uuid,String categoryName,String discoveryName,String discoveryDate){ - new BukkitRunnable(){ - @Override - public void run() { - try(Connection connection = getConnection()){ - PreparedStatement statement = null; - statement = connection.prepareStatement( - "INSERT INTO codex_players_discoveries " + - "(UUID, CATEGORY, DISCOVERY, DATE, MILLIS_ACTIONS_EXECUTED) VALUE (?,?,?,?,?)"); - - statement.setString(1, uuid); - statement.setString(2, categoryName); - statement.setString(3, discoveryName); - statement.setString(4, discoveryDate); - statement.setLong(5, 0); - statement.executeUpdate(); - } catch (SQLException e) { - e.printStackTrace(); - } - } - }.runTaskAsynchronously(plugin); - } - - public void updateMillisActionsExecuted(String uuid, String categoryName, String discoveryName) { - new BukkitRunnable(){ - @Override - public void run() { - try(Connection connection = getConnection()){ - PreparedStatement statement = connection.prepareStatement( - "UPDATE codex_players_discoveries SET " + - "MILLIS_ACTIONS_EXECUTED=? WHERE UUID=? AND CATEGORY=? AND DISCOVERY=?"); - - statement.setLong(1, System.currentTimeMillis()); - statement.setString(2, uuid); - statement.setString(3, categoryName); - statement.setString(4, discoveryName); - statement.executeUpdate(); - } catch (SQLException e) { - e.printStackTrace(); - } - } - }.runTaskAsynchronously(plugin); - } - - public void updateCompletedCategories(String uuid,String categoryName){ - new BukkitRunnable(){ - @Override - public void run() { - try(Connection connection = getConnection()){ - PreparedStatement statement = null; - statement = connection.prepareStatement( - "INSERT INTO codex_players_completed_categories " + - "(UUID, CATEGORY) VALUE (?,?)"); - - statement.setString(1, uuid); - statement.setString(2, categoryName); - statement.executeUpdate(); - } catch (SQLException e) { - e.printStackTrace(); - } - } - }.runTaskAsynchronously(plugin); - } - - public void resetDataPlayer(String uuid, String categoryName, String discoveryName){ - new BukkitRunnable(){ - @Override - public void run() { - try(Connection connection = getConnection()){ - PreparedStatement statement = null; - PreparedStatement statement2 = null; - - if(categoryName == null) { - if (uuid.equals("*")) { - // Reset all data for all players - statement = connection.prepareStatement( - "DELETE FROM codex_players_discoveries"); - statement2 = connection.prepareStatement( - "DELETE FROM codex_players_completed_categories"); - }else{ - // Reset all data for player - statement = connection.prepareStatement( - "DELETE FROM codex_players_discoveries WHERE UUID=?"); - statement.setString(1, uuid); - statement2 = connection.prepareStatement( - "DELETE FROM codex_players_completed_categories WHERE UUID=?"); - statement2.setString(1, uuid); - } - }else{ - if(discoveryName == null) { - if (uuid.equals("*")) { - // Reset category for all players - statement = connection.prepareStatement( - "DELETE FROM codex_players_discoveries WHERE CATEGORY=?"); - statement.setString(1, categoryName); - statement2 = connection.prepareStatement( - "DELETE FROM codex_players_completed_categories WHERE CATEGORY=?"); - statement2.setString(1, categoryName); - }else{ - // Reset category for player - statement = connection.prepareStatement( - "DELETE FROM codex_players_discoveries WHERE UUID=? AND CATEGORY=?"); - statement.setString(1, uuid);statement.setString(2, categoryName); - statement2 = connection.prepareStatement( - "DELETE FROM codex_players_completed_categories WHERE UUID=? AND CATEGORY=?"); - statement2.setString(1, uuid);statement2.setString(2, categoryName); - } - }else{ - if (uuid.equals("*")) { - // Reset discovery for all players - statement = connection.prepareStatement( - "DELETE FROM codex_players_discoveries WHERE CATEGORY=? AND DISCOVERY=?"); - statement.setString(1, categoryName);statement.setString(2, discoveryName); - }else{ - // Reset discovery for player - statement = connection.prepareStatement( - "DELETE FROM codex_players_discoveries WHERE UUID=? AND CATEGORY=? AND DISCOVERY=?"); - statement.setString(1, uuid);statement.setString(2, categoryName); - statement.setString(3, discoveryName); - } - } - } - - statement.executeUpdate(); - if(statement2 != null){ - statement2.executeUpdate(); - } - } catch (SQLException e) { - e.printStackTrace(); - } - } - }.runTaskAsynchronously(plugin); - } -} diff --git a/src/main/java/net/tfminecraft/codex/database/PlayerCallback.java b/src/main/java/net/tfminecraft/codex/database/PlayerCallback.java deleted file mode 100644 index aa6d694..0000000 --- a/src/main/java/net/tfminecraft/codex/database/PlayerCallback.java +++ /dev/null @@ -1,9 +0,0 @@ -package net.tfminecraft.codex.database; - - -import net.tfminecraft.codex.model.data.PlayerData; - -public interface PlayerCallback { - - void onDone(PlayerData player); -} diff --git a/src/main/java/net/tfminecraft/codex/listeners/PlayerListener.java b/src/main/java/net/tfminecraft/codex/listeners/PlayerListener.java index 4827984..10e4b7f 100644 --- a/src/main/java/net/tfminecraft/codex/listeners/PlayerListener.java +++ b/src/main/java/net/tfminecraft/codex/listeners/PlayerListener.java @@ -3,12 +3,9 @@ import net.tfminecraft.codex.Codex; import org.bukkit.ChatColor; import org.bukkit.entity.LivingEntity; -import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.entity.EntityDeathEvent; -import org.bukkit.event.player.PlayerJoinEvent; -import org.bukkit.event.player.PlayerQuitEvent; public class PlayerListener implements Listener { @@ -17,11 +14,6 @@ public PlayerListener(Codex plugin){ this.plugin = plugin; } - @EventHandler - public void onJoin(PlayerJoinEvent event){ - plugin.getPlayerDataManager().manageJoin(event.getPlayer()); - } - @EventHandler public void onMobKill(EntityDeathEvent event){ LivingEntity e = event.getEntity(); @@ -30,9 +22,4 @@ public void onMobKill(EntityDeathEvent event){ plugin.getDiscoveryManager().onMobKill(e.getKiller(),e.getType().name(),customName); } } - - @EventHandler - public void onLeave(PlayerQuitEvent event){ - plugin.getPlayerDataManager().manageLeave(event.getPlayer()); - } } diff --git a/src/main/java/net/tfminecraft/codex/managers/DiscoveryManager.java b/src/main/java/net/tfminecraft/codex/managers/DiscoveryManager.java index f4126fb..cec107a 100644 --- a/src/main/java/net/tfminecraft/codex/managers/DiscoveryManager.java +++ b/src/main/java/net/tfminecraft/codex/managers/DiscoveryManager.java @@ -137,7 +137,8 @@ public boolean onDiscover(Player player,String categoryName,String discoveryName Discovery discovery = category.getDiscovery(discoveryName); PlayerDataManager playerDataManager = plugin.getPlayerDataManager(); - if(playerDataManager.hasDiscovery(player,categoryName,discoveryName)){ + // Between characters there is no journal to record the find, so no rewards either. + if(!playerDataManager.isLoaded(player) || playerDataManager.hasDiscovery(player,categoryName,discoveryName)){ return false; } playerDataManager.addDiscovery(player,categoryName,discoveryName); diff --git a/src/main/java/net/tfminecraft/codex/managers/PlayerDataManager.java b/src/main/java/net/tfminecraft/codex/managers/PlayerDataManager.java index b08a553..7e44ca4 100644 --- a/src/main/java/net/tfminecraft/codex/managers/PlayerDataManager.java +++ b/src/main/java/net/tfminecraft/codex/managers/PlayerDataManager.java @@ -1,8 +1,6 @@ package net.tfminecraft.codex.managers; import net.tfminecraft.codex.Codex; -import net.tfminecraft.codex.config.PlayersConfigManager; -import net.tfminecraft.codex.database.MySQLConnection; import net.tfminecraft.codex.model.data.PlayerData; import net.tfminecraft.codex.model.data.PlayerDataCategory; import net.tfminecraft.codex.model.data.PlayerDataDiscovery; @@ -13,7 +11,6 @@ import org.bukkit.Bukkit; import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.entity.Player; -import org.bukkit.scheduler.BukkitRunnable; import java.util.*; @@ -38,20 +35,13 @@ public void addPlayer(PlayerData p){ playerNames.put(p.getName(), p.getUuid()); } - public PlayerData getPlayer(Player player, boolean create){ - PlayerData playerData = players.get(player.getUniqueId()); - if(playerData == null && create){ - playerData = new PlayerData(player.getUniqueId(),player.getName()); - addPlayer(playerData); - } - return playerData; + /** The journal of the character the player is playing, or null between characters. */ + public PlayerData getPlayer(Player player){ + return players.get(player.getUniqueId()); } - private void updatePlayerName(String oldName,String newName,UUID uuid){ - if(oldName != null){ - playerNames.remove(oldName); - } - playerNames.put(newName,uuid); + public boolean isLoaded(Player player){ + return players.containsKey(player.getUniqueId()); } public PlayerData getPlayerByUUID(UUID uuid){ @@ -76,73 +66,19 @@ public void removePlayerByUUID(UUID uuid){ players.remove(uuid); } - public void manageJoin(Player player){ - if(plugin.getMySQLConnection() != null) { - MySQLConnection mySQLConnection = plugin.getMySQLConnection(); - UUID uuid = player.getUniqueId(); - mySQLConnection.getPlayer(uuid.toString(), playerData -> { - if (playerData != null) { - addPlayer(playerData); - //Update name if different - if (!playerData.getName().equals(player.getName())) { - updatePlayerName(playerData.getName(), player.getName(), player.getUniqueId()); - playerData.setName(player.getName()); - mySQLConnection.updatePlayerName(playerData); - } - } else { - playerData = new PlayerData(uuid, player.getName()); - addPlayer(playerData); - //Create if it doesn't exist - mySQLConnection.createPlayer(playerData); - } - }); - }else{ - // Load player data from file if exists - plugin.getConfigsManager().getPlayersConfigManager().loadConfig(player.getUniqueId(), playerData -> { - if(playerData != null){ - addPlayer(playerData); - if(playerData.getName() == null || !playerData.getName().equals(player.getName())){ - updatePlayerName(playerData.getName(),player.getName(),player.getUniqueId()); - playerData.setName(player.getName()); - playerData.setModified(true); - } - } - }); - } - } - - public void manageLeave(Player player){ - // Save player data into file and remove from map - PlayerData playerData = getPlayer(player,false); - if(playerData != null){ - if(plugin.getMySQLConnection() == null) { - if(playerData.isModified()){ - new BukkitRunnable(){ - @Override - public void run() { - plugin.getConfigsManager().getPlayersConfigManager().saveConfig(playerData); - } - }.runTaskAsynchronously(plugin); - } - } - - removePlayer(playerData); - } - } - public void addDiscovery(Player player,String categoryName,String discoveryName){ - PlayerData playerData = getPlayer(player,true); + PlayerData playerData = getPlayer(player); + if(playerData == null){ + return; + } playerData.setModified(true); String date = OtherUtils.getDate(plugin.getConfigsManager().getMainConfigManager().getDiscoveriesDateFormat()); playerData.addDiscovery(categoryName,discoveryName,date); - if(plugin.getMySQLConnection() != null){ - plugin.getMySQLConnection().addDiscovery(player.getUniqueId().toString(),categoryName,discoveryName,date); - } } public boolean hasDiscovery(Player player,String categoryName,String discoveryName){ - PlayerData playerData = getPlayer(player,false); + PlayerData playerData = getPlayer(player); if(playerData == null){ return false; } @@ -150,7 +86,7 @@ public boolean hasDiscovery(Player player,String categoryName,String discoveryNa } public PlayerDataDiscovery getDiscovery(Player player,String categoryName,String discoveryName){ - PlayerData playerData = getPlayer(player,false); + PlayerData playerData = getPlayer(player); if(playerData == null){ return null; } @@ -158,7 +94,7 @@ public PlayerDataDiscovery getDiscovery(Player player,String categoryName,String } public int getTotalDiscoveries(Player player,String categoryName){ - PlayerData playerData = getPlayer(player,false); + PlayerData playerData = getPlayer(player); if(playerData == null){ return 0; } @@ -166,7 +102,7 @@ public int getTotalDiscoveries(Player player,String categoryName){ } public int getTotalDiscoveries(Player player){ - PlayerData playerData = getPlayer(player,false); + PlayerData playerData = getPlayer(player); if(playerData == null){ return 0; } @@ -184,16 +120,16 @@ public int getTotalDiscoveriesPercentage(Player player,int max){ } public void completeCategory(Player player,String categoryName){ - PlayerData playerData = getPlayer(player,true); - playerData.completeCategory(categoryName); - - if(plugin.getMySQLConnection() != null){ - plugin.getMySQLConnection().updateCompletedCategories(player.getUniqueId().toString(),categoryName); + PlayerData playerData = getPlayer(player); + if(playerData == null){ + return; } + playerData.setModified(true); + playerData.completeCategory(categoryName); } public boolean hasAllDiscoveries(Player player,String categoryName,int totalDiscoveries){ - PlayerData playerData = getPlayer(player,false); + PlayerData playerData = getPlayer(player); if(playerData == null){ return false; } @@ -202,7 +138,7 @@ public boolean hasAllDiscoveries(Player player,String categoryName,int totalDisc } public List getCategories(Player player){ - PlayerData playerData = getPlayer(player,false); + PlayerData playerData = getPlayer(player); if(playerData == null){ return new ArrayList<>(); } @@ -211,7 +147,7 @@ public List getCategories(Player player){ } public boolean hasCompletedCategory(Player player,String categoryName){ - PlayerData playerData = getPlayer(player,false); + PlayerData playerData = getPlayer(player); if(playerData == null){ return false; } @@ -227,17 +163,16 @@ public boolean hasCompletedCategory(String playerName,String categoryName){ } public void setMillisActionsExecuted(Player player,String categoryName,String discoveryName){ - PlayerData playerData = getPlayer(player,true); + PlayerData playerData = getPlayer(player); + if(playerData == null){ + return; + } playerData.setModified(true); playerData.setMillisActionsExecuted(categoryName,discoveryName); - - if(plugin.getMySQLConnection() != null){ - plugin.getMySQLConnection().updateMillisActionsExecuted(player.getUniqueId().toString(),categoryName,discoveryName); - } } public long getMillisActionsExecuted(Player player,String categoryName,String discoveryName){ - PlayerData playerData = getPlayer(player,false); + PlayerData playerData = getPlayer(player); if(playerData == null){ return 0; } @@ -266,68 +201,41 @@ public String resetDataForPlayer(String playerName, String categoryName, String } } - MySQLConnection mySQLConnection = plugin.getMySQLConnection(); if(categoryName == null){ // Reset all data for player playerData.resetData(); - if(mySQLConnection != null) mySQLConnection.resetDataPlayer(playerData.getUuid().toString(),null,null); return messagesConfig.getString("commandResetAllForPlayer").replace("%player%",playerName); } if(discoveryName == null){ // Reset category for player playerData.resetCategory(categoryName); - if(mySQLConnection != null) mySQLConnection.resetDataPlayer(playerData.getUuid().toString(),categoryName,null); return messagesConfig.getString("commandResetCategoryForPlayer").replace("%player%",playerName) .replace("%category%",categoryName); }else{ // Reset discovery for player playerData.resetDiscovery(categoryName,discoveryName); - if(mySQLConnection != null) mySQLConnection.resetDataPlayer(playerData.getUuid().toString(),categoryName,discoveryName); return messagesConfig.getString("commandResetDiscoveryForPlayer").replace("%player%",playerName) .replace("%category%",categoryName).replace("%discovery%",discoveryName); } } public void resetDataForAllPlayers(String categoryName, String discoveryName, FileConfiguration messagesConfig, GenericCallback callback){ - new BukkitRunnable(){ - @Override - public void run() { - MySQLConnection mySQLConnection = plugin.getMySQLConnection(); - if(mySQLConnection == null){ - PlayersConfigManager playersConfigsManager = plugin.getConfigsManager().getPlayersConfigManager(); - playersConfigsManager.resetDataForAllPlayers(categoryName,discoveryName); - } - - new BukkitRunnable(){ - @Override - public void run() { - - String result; - if(categoryName == null){ - // Reset all data for all players (online and/or sql) - players.values().forEach(PlayerData::resetData); - if(mySQLConnection != null) mySQLConnection.resetDataPlayer("*",null,null); - result = messagesConfig.getString("commandResetAllForAllPlayers"); - }else{ - if(discoveryName == null){ - // Reset category for all players (online and/or sql) - players.values().forEach(p -> p.resetCategory(categoryName)); - if(mySQLConnection != null) mySQLConnection.resetDataPlayer("*",categoryName,null); - result = messagesConfig.getString("commandResetCategoryForAllPlayers").replace("%category%",categoryName); - }else{ - // Reset discovery for all players - players.values().forEach(p -> p.resetDiscovery(categoryName,discoveryName)); - if(mySQLConnection != null) mySQLConnection.resetDataPlayer("*",categoryName,discoveryName); - result = messagesConfig.getString("commandResetDiscoveryForAllPlayers").replace("%category%",categoryName) - .replace("%discovery%",discoveryName); - } - } - - callback.onDone(result); - } - }.runTask(plugin); - } - }.runTaskAsynchronously(plugin); + // Offline characters are reset in their files, played characters in memory and saved later. + plugin.getConfigsManager().getPlayersConfigManager().resetDataForAllPlayers(categoryName,discoveryName); + + String result; + if(categoryName == null){ + players.values().forEach(PlayerData::resetData); + result = messagesConfig.getString("commandResetAllForAllPlayers"); + }else if(discoveryName == null){ + players.values().forEach(p -> p.resetCategory(categoryName)); + result = messagesConfig.getString("commandResetCategoryForAllPlayers").replace("%category%",categoryName); + }else{ + players.values().forEach(p -> p.resetDiscovery(categoryName,discoveryName)); + result = messagesConfig.getString("commandResetDiscoveryForAllPlayers").replace("%category%",categoryName) + .replace("%discovery%",discoveryName); + } + callback.onDone(result); } } diff --git a/src/main/java/net/tfminecraft/codex/model/data/PlayerData.java b/src/main/java/net/tfminecraft/codex/model/data/PlayerData.java index e0c3286..ac499bd 100644 --- a/src/main/java/net/tfminecraft/codex/model/data/PlayerData.java +++ b/src/main/java/net/tfminecraft/codex/model/data/PlayerData.java @@ -4,14 +4,17 @@ import java.util.List; import java.util.UUID; +/** One character's discoveries, held while its owner plays it. {@code uuid} is the owner's account. */ public class PlayerData { private UUID uuid; + private UUID characterId; private String name; private List categories; private boolean modified; - public PlayerData(UUID uuid, String name){ + public PlayerData(UUID uuid, UUID characterId, String name){ this.uuid = uuid; + this.characterId = characterId; this.name = name; this.modified = false; this.categories = new ArrayList<>(); @@ -25,6 +28,10 @@ public void setUuid(UUID uuid) { this.uuid = uuid; } + public UUID getCharacterId() { + return characterId; + } + public String getName() { return name; } @@ -141,6 +148,34 @@ public long getMillisActionsExecuted(String category,String discovery){ return playerDataCategory.getMillisActionsExecuted(discovery); } + /** + * Adds discoveries this character lacks, keeping its own entries. A category counts as completed + * when either side completed it, so completion rewards are not paid twice. + * @return how many discoveries were added + */ + public int absorb(List others){ + int added = 0; + for(PlayerDataCategory other : others){ + PlayerDataCategory own = getCategory(other.getName()); + if(own == null){ + own = new PlayerDataCategory(other.getName(),false,new ArrayList<>()); + categories.add(own); + } + if(other.isCompleted()){ + own.setCompleted(true); + } + for(PlayerDataDiscovery discovery : other.getDiscoveries()){ + if(!own.hasDiscovery(discovery.getDiscoveryName())){ + own.addDiscovery(new PlayerDataDiscovery(discovery.getDiscoveryName(), + discovery.getDiscoverDate(),discovery.getMillisActionsExecuted())); + added++; + } + } + } + modified = true; + return added; + } + public void resetData(){ categories = new ArrayList<>(); modified = true; diff --git a/src/main/java/net/tfminecraft/codex/tasks/PlayerDataSaveTask.java b/src/main/java/net/tfminecraft/codex/tasks/PlayerDataSaveTask.java index 4872b6e..444b24b 100644 --- a/src/main/java/net/tfminecraft/codex/tasks/PlayerDataSaveTask.java +++ b/src/main/java/net/tfminecraft/codex/tasks/PlayerDataSaveTask.java @@ -29,7 +29,7 @@ public void run() { } } - }.runTaskTimerAsynchronously(plugin, 0L, ticks); + }.runTaskTimer(plugin, ticks, ticks); } public void execute() { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index eecb603..9ae8754 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -15,21 +15,6 @@ progress_bar_placeholder: # Only works on Paper 1.19.4+. use_minimessage: false -# MySQL support credentials. -mysql_database: - enabled: false - host: localhost - port: 3306 - username: root - password: root - database: database - pool: - connectionTimeout: 5000 - advanced: - verifyServerCertificate: false - useSSL: true - allowPublicKeyRetrieval: true - # Don't modify. config_version: 2 diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 4b02c4a..24ce6dd 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -3,7 +3,8 @@ version: ${project.version} name: Codex api-version: '1.21.10' author: Ajneb97 -softdepend: [WorldGuard,WorldEdit,PlaceholderAPI,MythicMobs,Residence] +depend: [TLibs] +softdepend: [RPCharacters,WorldGuard,WorldEdit,PlaceholderAPI,MythicMobs,Residence] commands: codex: diff --git a/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java b/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java new file mode 100644 index 0000000..c393956 --- /dev/null +++ b/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java @@ -0,0 +1,334 @@ +package net.tfminecraft.codex.character; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +import net.tfminecraft.codex.Codex; +import net.tfminecraft.codex.managers.PlayerDataManager; +import net.tfminecraft.codex.model.data.PlayerData; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.CharacterSessionEndEvent; +import net.tfminecraft.tlibs.character.CharacterSessionStartEvent; +import net.tfminecraft.tlibs.character.Characters; + +class CharacterJournalsTest { + @TempDir + Path folder; + + private final UUID owner = UUID.randomUUID(); + private final UUID first = UUID.randomUUID(); + private final UUID second = UUID.randomUUID(); + private final List logs = new ArrayList<>(); + private MockedStatic characters; + private PlayerDataManager players; + private CharacterStore store; + private CharacterJournals journals; + private Player player; + + @BeforeEach + void setUp() { + characters = mockStatic(Characters.class); + characters.when(Characters::legacyHeirsReady).thenReturn(true); + characters.when(() -> Characters.legacyHeir(owner)).thenReturn(Optional.of(new CharacterRef(owner, first))); + players = new PlayerDataManager(mock(Codex.class)); + store = new CharacterStore(folder); + Logger logger = Logger.getAnonymousLogger(); + logger.setUseParentHandlers(false); + logger.addHandler(new Handler() { + @Override + public void publish(LogRecord record) { + logs.add(record); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }); + journals = new CharacterJournals(players, store, logger); + player = mock(Player.class); + when(player.getUniqueId()).thenReturn(owner); + when(player.getName()).thenReturn("Steve"); + } + + @AfterEach + void tearDown() { + characters.close(); + } + + @Test + void startingACharacterOpensItsOwnJournal() throws IOException { + saved(second, "aurea"); + + journals.onStart(start(second)); + + PlayerData data = players.getPlayer(player); + assertEquals(second, data.getCharacterId()); + assertEquals("Steve", data.getName()); + assertTrue(data.hasDiscovery("poi", "aurea")); + } + + @Test + void switchingSavesTheOutgoingJournalFirst() throws IOException { + journals.onStart(start(first)); + players.getPlayer(player).addDiscovery("poi", "vard", "14/10/2026"); + + journals.onStart(start(second)); + + assertEquals(second, players.getPlayer(player).getCharacterId()); + assertFalse(players.getPlayer(player).hasDiscovery("poi", "vard")); + assertTrue(store.load(owner, first, "Steve").hasDiscovery("poi", "vard")); + } + + @Test + void theHeirInheritsTheAccountJournalOnItsFirstSession() throws IOException { + legacy("syrindell"); + + journals.onStart(start(first)); + + assertTrue(players.getPlayer(player).hasDiscovery("poi", "syrindell")); + assertFalse(store.hasLegacy(owner)); + assertTrue(Files.exists(folder.resolve("players-migrated").resolve(owner + ".yml"))); + } + + @Test + void otherCharactersStartWithoutTheAccountJournal() throws IOException { + legacy("syrindell"); + + journals.onStart(start(second)); + + assertFalse(players.getPlayer(player).hasDiscovery("poi", "syrindell")); + assertTrue(store.load(owner, first, "Steve").hasDiscovery("poi", "syrindell")); + assertTrue(Files.readString(store.characterFile(first)).contains("name: Steve")); + } + + @Test + void anHeirAlreadyPlayingGetsTheDiscoveriesInMemory() throws IOException { + characters.when(Characters::legacyHeirsReady).thenReturn(false); + journals.onStart(start(first)); + PlayerData live = players.getPlayer(player); + live.addDiscovery("poi", "vard", "14/10/2026"); + legacy("syrindell"); + characters.when(Characters::legacyHeirsReady).thenReturn(true); + + assertTrue(journals.migrate(owner)); + + assertSame(live, players.getPlayer(player)); + assertTrue(live.hasDiscovery("poi", "syrindell")); + assertTrue(live.hasDiscovery("poi", "vard")); + assertTrue(store.load(owner, first, "Steve").hasDiscovery("poi", "vard")); + } + + @Test + void nothingMovesBeforeHeirsAreKnownOrWithoutAnOldFile() throws IOException { + assertFalse(journals.migrate(owner)); + + legacy("syrindell"); + characters.when(Characters::legacyHeirsReady).thenReturn(false); + journals.onStart(start(first)); + + assertFalse(players.getPlayer(player).hasDiscovery("poi", "syrindell")); + assertTrue(store.hasLegacy(owner)); + } + + @Test + void anUnreadableOldFileStaysForStaff() throws IOException { + Files.createDirectories(folder.resolve("players")); + Files.writeString(folder.resolve("players").resolve(owner + ".yml"), "name: [unclosed"); + + assertFalse(journals.migrate(owner)); + + assertTrue(store.hasLegacy(owner)); + assertLogged(Level.SEVERE, "Could not move the Codex of account " + owner); + } + + @Test + void anUnreadableJournalStaysClosed() throws IOException { + Files.createDirectories(folder.resolve("characters")); + Files.writeString(store.characterFile(first), "categories: [unclosed"); + + journals.onStart(start(first)); + + assertNull(players.getPlayer(player)); + assertLogged(Level.SEVERE, "could not be read"); + } + + @Test + void endingASessionSavesOnlyThatCharacter() throws IOException { + journals.onStart(start(first)); + players.getPlayer(player).addDiscovery("poi", "vard", "14/10/2026"); + + journals.onEnd(end(second)); + assertNotNull(players.getPlayer(player)); + + journals.onEnd(end(first)); + assertNull(players.getPlayer(player)); + assertTrue(store.load(owner, first, "Steve").hasDiscovery("poi", "vard")); + } + + @Test + void quittingSavesAndClosesAnyJournal() throws IOException { + journals.onQuit(new PlayerQuitEvent(player, "bye")); + journals.onStart(start(first)); + + journals.onQuit(new PlayerQuitEvent(player, "bye")); + + assertNull(players.getPlayer(player)); + assertTrue(Files.exists(store.characterFile(first))); + } + + @Test + void unchangedJournalsAreNotRewritten() throws IOException { + saved(first, "aurea"); + journals.onStart(start(first)); + Files.delete(store.characterFile(first)); + + journals.saveAll(); + + assertFalse(Files.exists(store.characterFile(first))); + } + + @Test + void aFailedSaveIsLogged() throws IOException { + journals.onStart(start(first)); + Files.writeString(folder.resolve("characters"), "not a folder"); + + journals.saveAll(); + + assertLogged(Level.SEVERE, "Could not save Codex for character " + first); + } + + @Test + void startOpensPlayedCharactersThenMovesOldFilesWhenHeirsAreKnown() throws IOException { + legacy("syrindell"); + UUID other = UUID.randomUUID(); + Files.writeString(folder.resolve("players").resolve(other + ".yml"), "name: Alex\n"); + characters.when(() -> Characters.characters(other)).thenReturn(Optional.of(List.of())); + characters.when(() -> Characters.currentSession(player)) + .thenReturn(Optional.of(new CharacterSession(new CharacterRef(owner, second), 1))); + characters.when(Characters::legacyHeirsReady).thenReturn(false); + Plugin plugin = mock(Plugin.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + BukkitTask task = mock(BukkitTask.class); + ArgumentCaptor tick = ArgumentCaptor.forClass(Runnable.class); + when(scheduler.runTaskTimer(eq(plugin), tick.capture(), anyLong(), anyLong())).thenReturn(task); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(player)); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + + journals.start(plugin); + assertEquals(second, players.getPlayer(player).getCharacterId()); + + tick.getValue().run(); + verify(task, never()).cancel(); + assertTrue(store.hasLegacy(owner)); + + characters.when(Characters::legacyHeirsReady).thenReturn(true); + tick.getValue().run(); + } + + verify(task).cancel(); + assertFalse(store.hasLegacy(owner)); + assertTrue(store.hasLegacy(other)); + assertLogged(Level.INFO, "Moved 1 of 2 account Codex files"); + + journals.stop(); + assertTrue(Files.exists(store.characterFile(second))); + } + + @Test + void stopWithoutStartStillSaves() { + journals.onStart(start(first)); + + journals.stop(); + + assertTrue(Files.exists(store.characterFile(first))); + } + + @Test + void anEmptyOrUnlistableOldFolderIsHandled() throws IOException { + journals.migrateAll(); + assertTrue(logs.isEmpty()); + + Path old = Files.createDirectories(folder.resolve("players")); + assertTrue(old.toFile().setReadable(false)); + try { + journals.migrateAll(); + } finally { + old.toFile().setReadable(true); + } + assertLogged(Level.SEVERE, "Could not list pre-character Codex files"); + } + + private CharacterSessionStartEvent start(UUID character) { + return new CharacterSessionStartEvent(player, new CharacterSession(new CharacterRef(owner, character), 1)); + } + + private CharacterSessionEndEvent end(UUID character) { + return new CharacterSessionEndEvent(player, new CharacterSession(new CharacterRef(owner, character), 1), + CharacterSessionEndEvent.Reason.SWITCH); + } + + private void saved(UUID character, String discovery) throws IOException { + PlayerData data = new PlayerData(owner, character, "Steve"); + data.addDiscovery("poi", discovery, "01/10/2026"); + store.save(data); + } + + private void legacy(String discovery) throws IOException { + Files.createDirectories(folder.resolve("players")); + Files.writeString(folder.resolve("players").resolve(owner + ".yml"), """ + name: Steve + categories: + poi: + completed: false + discoveries: + - %s;24/09/2026;0 + """.formatted(discovery)); + } + + private void assertLogged(Level level, String text) { + assertTrue(logs.stream().anyMatch(record -> record.getLevel().equals(level) && record.getMessage().contains(text)), + () -> "missing " + level + " log containing: " + text); + } +} diff --git a/src/test/java/net/tfminecraft/codex/character/CharacterStoreTest.java b/src/test/java/net/tfminecraft/codex/character/CharacterStoreTest.java new file mode 100644 index 0000000..8d5053d --- /dev/null +++ b/src/test/java/net/tfminecraft/codex/character/CharacterStoreTest.java @@ -0,0 +1,165 @@ +package net.tfminecraft.codex.character; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import net.tfminecraft.codex.model.data.PlayerData; +import net.tfminecraft.codex.model.data.PlayerDataCategory; +import net.tfminecraft.codex.model.data.PlayerDataDiscovery; + +class CharacterStoreTest { + @TempDir + Path folder; + + private CharacterStore store; + private final UUID owner = UUID.randomUUID(); + private final UUID character = UUID.randomUUID(); + + @BeforeEach + void setUp() { + store = new CharacterStore(folder); + } + + @Test + void newCharacterStartsEmptyAndIsSavedLater() throws IOException { + PlayerData data = store.load(owner, character, "Steve"); + + assertEquals(owner, data.getUuid()); + assertEquals(character, data.getCharacterId()); + assertEquals("Steve", data.getName()); + assertTrue(data.getCategories().isEmpty()); + assertTrue(data.isModified()); + } + + @Test + void savedJournalLoadsBackWithEveryField() throws IOException { + PlayerData data = store.load(owner, character, "Steve"); + data.addDiscovery("poi", "syrindell", "24/09/2026"); + data.getCategory("poi").getDiscovery("syrindell").setMillisActionsExecuted(42); + data.completeCategory("poi"); + + store.save(data); + + assertFalse(data.isModified()); + String text = Files.readString(store.characterFile(character)); + assertTrue(text.contains("owner: " + owner)); + assertTrue(text.contains("name: Steve")); + assertFalse(Files.exists(folder.resolve("characters").resolve(character + ".yml.tmp"))); + + PlayerData loaded = store.load(owner, character, "Alex"); + assertFalse(loaded.isModified()); + assertEquals("Alex", loaded.getName()); + PlayerDataCategory poi = loaded.getCategory("poi"); + assertTrue(poi.isCompleted()); + PlayerDataDiscovery found = poi.getDiscovery("syrindell"); + assertEquals("24/09/2026", found.getDiscoverDate()); + assertEquals(42, found.getMillisActionsExecuted()); + } + + @Test + void oldEntriesWithoutMillisOrDateStillLoad() throws IOException { + write("characters", character, """ + categories: + poi: + discoveries: + - aurea;01/10/2026 + - vard + - thalendor;02/10/2026;soon + - ;03/10/2026;0 + """); + + PlayerDataCategory poi = store.load(owner, character, "Steve").getCategory("poi"); + + assertFalse(poi.isCompleted()); + assertEquals(3, poi.getDiscoveries().size()); + assertEquals(0, poi.getDiscovery("aurea").getMillisActionsExecuted()); + assertEquals("", poi.getDiscovery("vard").getDiscoverDate()); + assertEquals(0, poi.getDiscovery("thalendor").getMillisActionsExecuted()); + } + + @Test + void unreadableFilesAreErrorsNotEmptyJournals() throws IOException { + write("characters", character, "categories: [unclosed"); + write("players", owner, "name: [unclosed"); + + assertThrows(IOException.class, () -> store.load(owner, character, "Steve")); + assertThrows(IOException.class, () -> store.readLegacy(owner)); + } + + @Test + void failedSaveKeepsTheJournalModified() throws IOException { + Files.writeString(folder.resolve("characters"), "not a folder"); + PlayerData data = new PlayerData(owner, character, "Steve"); + data.setModified(true); + + assertThrows(IOException.class, () -> store.save(data)); + assertTrue(data.isModified()); + } + + @Test + void legacyOwnersAreTheAccountFiles() throws IOException { + assertTrue(store.legacyOwners().isEmpty()); + + write("players", owner, "name: Steve\n"); + Files.writeString(folder.resolve("players").resolve("notes.yml"), "x: 1\n"); + Files.writeString(folder.resolve("players").resolve(UUID.randomUUID() + ".txt"), "x: 1\n"); + + assertEquals(List.of(owner), store.legacyOwners()); + } + + @Test + void legacyJournalIsReadThenRetired() throws IOException { + assertFalse(store.hasLegacy(owner)); + write("players", owner, """ + name: Steve + categories: + achievements: + completed: true + discoveries: + - die;20/09/2026;0 + """); + write("players-migrated", owner, "name: Older\n"); + + CharacterStore.LegacyJournal legacy = store.readLegacy(owner); + + assertTrue(store.hasLegacy(owner)); + assertEquals("Steve", legacy.name()); + assertTrue(legacy.categories().get(0).isCompleted()); + assertTrue(legacy.categories().get(0).hasDiscovery("die")); + + store.retireLegacy(owner); + + assertFalse(store.hasLegacy(owner)); + assertTrue(Files.readString(folder.resolve("players-migrated").resolve(owner + ".yml")).contains("Steve")); + } + + @Test + void legacyJournalWithoutCategoriesIsEmpty() throws IOException { + write("players", owner, "x: 1\n"); + + CharacterStore.LegacyJournal legacy = store.readLegacy(owner); + + assertNull(legacy.name()); + assertEquals(new ArrayList<>(), legacy.categories()); + } + + private void write(String directory, UUID id, String text) throws IOException { + Path dir = folder.resolve(directory); + Files.createDirectories(dir); + Files.writeString(dir.resolve(id + ".yml"), text); + } +} diff --git a/src/test/java/net/tfminecraft/codex/character/HeirsTest.java b/src/test/java/net/tfminecraft/codex/character/HeirsTest.java new file mode 100644 index 0000000..918fc41 --- /dev/null +++ b/src/test/java/net/tfminecraft/codex/character/HeirsTest.java @@ -0,0 +1,79 @@ +package net.tfminecraft.codex.character; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mockStatic; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterStatus; +import net.tfminecraft.tlibs.character.Characters; + +class HeirsTest { + private final UUID owner = UUID.randomUUID(); + + @Test + void reviewedHeirWins() { + CharacterInfo played = info(CharacterStatus.ALIVE, 9000, null); + UUID reviewed = UUID.randomUUID(); + + assertEquals(Optional.of(reviewed), + Heirs.choose(Optional.of(new CharacterRef(owner, reviewed)), Optional.of(List.of(played)))); + } + + @Test + void otherwiseTheMostPlayedLivingCharacter() { + CharacterInfo dead = info(CharacterStatus.DEAD, 9000, null); + CharacterInfo short1 = info(CharacterStatus.ALIVE, 100, Instant.parse("2026-10-01T00:00:00Z")); + CharacterInfo long1 = info(CharacterStatus.ALIVE, 500, null); + CharacterInfo long2 = info(CharacterStatus.ALIVE, 500, Instant.parse("2026-10-02T00:00:00Z")); + + assertEquals(Optional.of(id(long2)), + Heirs.choose(Optional.empty(), Optional.of(List.of(dead, short1, long1, long2)))); + } + + @Test + void anAllDeadAccountFallsBackToItsMostPlayed() { + CharacterInfo less = info(CharacterStatus.DEAD, 10, null); + CharacterInfo more = info(CharacterStatus.MISSING, 20, null); + + assertEquals(Optional.of(id(more)), Heirs.choose(Optional.empty(), Optional.of(List.of(less, more)))); + } + + @Test + void noCharacterMeansNoHeirYet() { + assertEquals(Optional.empty(), Heirs.choose(Optional.empty(), Optional.of(List.of()))); + assertEquals(Optional.empty(), Heirs.choose(Optional.empty(), Optional.empty())); + } + + @Test + void waitsUntilRpCharactersKnowsItsHeirs() { + UUID reviewed = UUID.randomUUID(); + try (MockedStatic characters = mockStatic(Characters.class)) { + characters.when(Characters::legacyHeirsReady).thenReturn(false); + assertEquals(Optional.empty(), Heirs.of(owner)); + + characters.when(Characters::legacyHeirsReady).thenReturn(true); + characters.when(() -> Characters.legacyHeir(owner)).thenReturn(Optional.of(new CharacterRef(owner, reviewed))); + characters.when(() -> Characters.characters(owner)).thenReturn(Optional.of(List.of())); + assertEquals(Optional.of(reviewed), Heirs.of(owner)); + } + } + + private CharacterInfo info(CharacterStatus status, long playtime, Instant lastPlayed) { + return new CharacterInfo(new CharacterRef(owner, UUID.randomUUID()), "Name", status, null, Set.of(), + playtime, lastPlayed); + } + + private static UUID id(CharacterInfo info) { + return info.ref().characterId(); + } +} diff --git a/src/test/java/net/tfminecraft/codex/model/data/PlayerDataTest.java b/src/test/java/net/tfminecraft/codex/model/data/PlayerDataTest.java new file mode 100644 index 0000000..f12d5aa --- /dev/null +++ b/src/test/java/net/tfminecraft/codex/model/data/PlayerDataTest.java @@ -0,0 +1,32 @@ +package net.tfminecraft.codex.model.data; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +class PlayerDataTest { + @Test + void absorbAddsOnlyMissingDiscoveriesAndKeepsCompletion() { + PlayerData own = new PlayerData(UUID.randomUUID(), UUID.randomUUID(), "Steve"); + own.addDiscovery("poi", "vard", "14/10/2026"); + ArrayList poi = new ArrayList<>(List.of( + new PlayerDataDiscovery("vard", "01/09/2026", 5), + new PlayerDataDiscovery("aurea", "02/09/2026", 7))); + ArrayList achievements = new ArrayList<>(List.of(new PlayerDataDiscovery("die", "03/09/2026", 0))); + + int added = own.absorb(List.of(new PlayerDataCategory("poi", false, poi), + new PlayerDataCategory("achievements", true, achievements))); + + assertEquals(2, added); + assertTrue(own.isModified()); + assertEquals("14/10/2026", own.getDiscovery("poi", "vard").getDiscoverDate()); + assertEquals(7, own.getDiscovery("poi", "aurea").getMillisActionsExecuted()); + assertTrue(own.isCategoryCompleted("achievements")); + assertEquals(false, own.isCategoryCompleted("poi")); + } +} From 39e5b7634a5406701acb7551a34052e7aa4ba5e2 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:29:06 +0000 Subject: [PATCH 2/5] Keep discovered entries readable across an account's characters An entry any of the account's characters has found, alive or dead, now shows unlocked for out-of-character reference, sub-pages included. Its hover text names the living characters who can use it in character, and says when the played character knows it only out of character. Rediscovery, rewards and progress counts stay per character. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +- .../codex/character/AccountKnowledge.java | 85 +++++++++++++++++++ .../codex/character/CharacterJournals.java | 41 ++++++++- .../codex/config/MessagesConfigManager.java | 10 +++ .../codex/managers/InventoryManager.java | 45 ++++++++-- .../codex/managers/PlayerDataManager.java | 1 + .../tfminecraft/codex/utils/OtherUtils.java | 10 +++ src/main/resources/messages.yml | 3 + .../codex/character/AccountKnowledgeTest.java | 55 ++++++++++++ .../character/CharacterJournalsTest.java | 55 ++++++++++++ .../codex/utils/OtherUtilsTest.java | 16 ++++ 11 files changed, 319 insertions(+), 8 deletions(-) create mode 100644 src/main/java/net/tfminecraft/codex/character/AccountKnowledge.java create mode 100644 src/test/java/net/tfminecraft/codex/character/AccountKnowledgeTest.java create mode 100644 src/test/java/net/tfminecraft/codex/utils/OtherUtilsTest.java diff --git a/README.md b/README.md index 3b67da0..ac02f70 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,10 @@ kill and lore that commands unlock. `/codex` opens the journal, and each entry shows its text once it has been found. Each roleplay character keeps its own journal, so a new character discovers the -world afresh and earns the discovery rewards again. What an account found before -this goes to the character that inherited its belongings. This copy tracks upstream +world afresh and earns the discovery rewards again. An entry any of the account's +characters has found stays readable for out-of-character reference, and its hover +text names the living characters who can use it in character. What an account +found before this goes to the character that inherited its belongings. This copy tracks upstream 2.12.1, builds for Paper 1.21.10 and needs TLibs 2.6.1 with RPCharacters. It stores journals in files only, and has no update checker or bStats reporting. diff --git a/src/main/java/net/tfminecraft/codex/character/AccountKnowledge.java b/src/main/java/net/tfminecraft/codex/character/AccountKnowledge.java new file mode 100644 index 0000000..84ed4c7 --- /dev/null +++ b/src/main/java/net/tfminecraft/codex/character/AccountKnowledge.java @@ -0,0 +1,85 @@ +package net.tfminecraft.codex.character; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import net.tfminecraft.codex.model.data.PlayerData; +import net.tfminecraft.codex.model.data.PlayerDataCategory; +import net.tfminecraft.codex.model.data.PlayerDataDiscovery; + +/** + * What the account's other characters have discovered, read when a character's session starts. + * An entry any character found, alive or dead, stays readable for out-of-character reference; + * only living characters can use it in character. The played character's own journal is live, + * so it is checked separately. + */ +public final class AccountKnowledge { + /** A discovery made by other characters: a date one of them found it, and which living ones know it. */ + public record Known(String date, List livingNames) { + } + + private final String characterName; + private final Map others; + + AccountKnowledge(String characterName, Map others) { + this.characterName = characterName; + this.others = others; + } + + public static AccountKnowledge empty(String characterName) { + return new AccountKnowledge(characterName, Map.of()); + } + + public String characterName() { + return characterName; + } + + /** The other characters' discoveries, keyed {@code category:discovery}. */ + public Known other(String category, String discovery) { + return others.get(key(category, discovery)); + } + + /** Living characters who know the entry in character, the played character first when it does. */ + public List knownInCharacterBy(PlayerData current, String category, String discovery) { + List names = new ArrayList<>(); + if (current != null && current.hasDiscovery(category, discovery)) { + names.add(characterName); + } + Known known = other(category, discovery); + if (known != null) { + names.addAll(known.livingNames()); + } + return names; + } + + static String key(String category, String discovery) { + return category + ":" + discovery; + } + + /** Collects other characters' journals. Each is added once; dead characters count but are not named. */ + static final class Builder { + private final Map dates = new HashMap<>(); + private final Map> names = new HashMap<>(); + + void add(List categories, String name, boolean alive) { + for (PlayerDataCategory category : categories) { + for (PlayerDataDiscovery discovery : category.getDiscoveries()) { + String key = key(category.getName(), discovery.getDiscoveryName()); + dates.putIfAbsent(key, discovery.getDiscoverDate()); + List known = names.computeIfAbsent(key, ignored -> new ArrayList<>()); + if (alive) { + known.add(name); + } + } + } + } + + AccountKnowledge build(String characterName) { + Map others = new HashMap<>(); + names.forEach((key, known) -> others.put(key, new Known(dates.get(key), List.copyOf(known)))); + return new AccountKnowledge(characterName, Map.copyOf(others)); + } + } +} diff --git a/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java b/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java index b06e7b2..ee4aca8 100644 --- a/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java +++ b/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java @@ -1,8 +1,11 @@ package net.tfminecraft.codex.character; import java.io.IOException; +import java.nio.file.Files; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.logging.Level; @@ -19,6 +22,7 @@ import net.tfminecraft.codex.managers.PlayerDataManager; import net.tfminecraft.codex.model.data.PlayerData; +import net.tfminecraft.tlibs.character.CharacterInfo; import net.tfminecraft.tlibs.character.CharacterRef; import net.tfminecraft.tlibs.character.CharacterSessionEndEvent; import net.tfminecraft.tlibs.character.CharacterSessionStartEvent; @@ -26,12 +30,14 @@ /** * Gives each character its own journal: loaded when a character session starts and saved when it - * ends. Between characters a player has no journal, so nothing can be discovered. Main thread only. + * ends. Between characters a player has no journal, so nothing can be discovered. Alongside it sits + * what the account's other characters know, so entries stay readable out of character. Main thread only. */ public final class CharacterJournals implements Listener { private final PlayerDataManager players; private final CharacterStore store; private final Logger logger; + private final Map knowledge = new HashMap<>(); private BukkitTask sweep; public CharacterJournals(PlayerDataManager players, CharacterStore store, Logger logger) { @@ -80,6 +86,7 @@ void open(Player player, CharacterRef ref) { migrate(ref.ownerId()); try { players.addPlayer(store.load(ref.ownerId(), ref.characterId(), player.getName())); + knowledge.put(ref.ownerId(), knowledgeOf(ref, player.getName())); } catch (IOException ex) { logger.log(Level.SEVERE, "Codex for character " + ref.characterId() + " of " + player.getName() + " could not be read; nothing can be discovered until it is fixed", ex); @@ -94,6 +101,38 @@ void close(Player player, UUID characterId) { } save(data); players.removePlayer(data); + knowledge.remove(player.getUniqueId()); + } + + /** What the player's other characters know, or null between characters. */ + public AccountKnowledge knowledge(Player player) { + return knowledge.get(player.getUniqueId()); + } + + /** Rereads the other characters' journals, for example after staff reset them. */ + public void refreshKnowledge() { + for (PlayerData data : players.getPlayers().values()) { + knowledge.put(data.getUuid(), knowledgeOf(new CharacterRef(data.getUuid(), data.getCharacterId()), data.getName())); + } + } + + private AccountKnowledge knowledgeOf(CharacterRef ref, String fallbackName) { + String name = fallbackName; + AccountKnowledge.Builder builder = new AccountKnowledge.Builder(); + for (CharacterInfo info : Characters.characters(ref.ownerId()).orElse(List.of())) { + UUID id = info.ref().characterId(); + if (id.equals(ref.characterId())) { + name = info.name(); + } else if (Files.exists(store.characterFile(id))) { + try { + builder.add(store.load(ref.ownerId(), id, info.name()).getCategories(), info.name(), info.alive()); + } catch (IOException ex) { + logger.log(Level.WARNING, "Codex for character " + id + " could not be read; " + + "its discoveries are left out of its account's reference", ex); + } + } + } + return builder.build(name); } public void saveAll() { diff --git a/src/main/java/net/tfminecraft/codex/config/MessagesConfigManager.java b/src/main/java/net/tfminecraft/codex/config/MessagesConfigManager.java index d63cc77..79d9b50 100644 --- a/src/main/java/net/tfminecraft/codex/config/MessagesConfigManager.java +++ b/src/main/java/net/tfminecraft/codex/config/MessagesConfigManager.java @@ -10,6 +10,10 @@ import java.nio.file.Paths; public class MessagesConfigManager { + public static final String KNOWN_BY = "&7%characters% can use this in character."; + public static final String KNOWN_BY_NOBODY = "&7No living character can use this in character."; + public static final String OUT_OF_CHARACTER_ONLY = "&8&o%character% knows this only out of character."; + private Codex plugin; private CommonConfig configFile; @@ -52,6 +56,12 @@ public void checkUpdates(){ String text = new String(Files.readAllBytes(pathConfig)); FileConfiguration config = configFile.getConfig(); + if(!text.contains("knownInCharacterBy:")){ + config.set("knownInCharacterBy",KNOWN_BY); + config.set("knownInCharacterByNobody",KNOWN_BY_NOBODY); + config.set("knownOutOfCharacterOnly",OUT_OF_CHARACTER_ONLY); + configFile.saveConfig(); + } if(!text.contains("clickActionsCooldown:")){ config.set("clickActionsCooldown","&cYou must wait &7%time% &cbefore using this item again."); config.set("seconds","s"); diff --git a/src/main/java/net/tfminecraft/codex/managers/InventoryManager.java b/src/main/java/net/tfminecraft/codex/managers/InventoryManager.java index bfd3984..247c760 100644 --- a/src/main/java/net/tfminecraft/codex/managers/InventoryManager.java +++ b/src/main/java/net/tfminecraft/codex/managers/InventoryManager.java @@ -1,7 +1,10 @@ package net.tfminecraft.codex.managers; import net.tfminecraft.codex.Codex; +import net.tfminecraft.codex.character.AccountKnowledge; +import net.tfminecraft.codex.config.MessagesConfigManager; import net.tfminecraft.codex.config.MainConfigManager; +import net.tfminecraft.codex.model.data.PlayerData; import net.tfminecraft.codex.model.data.PlayerDataDiscovery; import net.tfminecraft.codex.model.internal.CommonVariable; import net.tfminecraft.codex.model.inventory.CommonInventory; @@ -148,9 +151,8 @@ private void clickOnDiscoveryItem(InventoryPlayer inventoryPlayer,String discove Player player = inventoryPlayer.getPlayer(); PlayerDataManager playerDataManager = plugin.getPlayerDataManager(); - boolean hasDiscovered = playerDataManager.hasDiscovery(player,category.getName(),discoveryName); - - if(!hasDiscovered){ + // Entries any of the account's characters found stay readable, sub-pages included. + if(!isSeen(player,category.getName(),discoveryName)){ return; } @@ -280,13 +282,16 @@ public ItemStack setDiscovery(String discoveryName,InventoryPlayer inventoryPlay CommonItem commonItem; variables.add(new CommonVariable("%name%",discovery.getName())); - if(playerDataDiscovery != null){ + AccountKnowledge knowledge = plugin.getCharacterJournals().knowledge(player); + AccountKnowledge.Known other = knowledge == null ? null : knowledge.other(category.getName(),discoveryName); + boolean seen = playerDataDiscovery != null || other != null; + if(seen){ if(discovery.getCustomLevelUnlockedItem() != null){ commonItem = discovery.getCustomLevelUnlockedItem(); }else{ commonItem = category.getDefaultLevelUnlockedItem(); } - variables.add(new CommonVariable("%date%",playerDataDiscovery.getDiscoverDate())); + variables.add(new CommonVariable("%date%",playerDataDiscovery != null ? playerDataDiscovery.getDiscoverDate() : other.date())); }else{ if(discovery.getCustomLevelBlockedItem() != null){ commonItem = discovery.getCustomLevelBlockedItem(); @@ -308,6 +313,9 @@ public ItemStack setDiscovery(String discoveryName,InventoryPlayer inventoryPlay newLore.add(s); } } + if(seen && knowledge != null){ + newLore.addAll(inCharacterLore(knowledge,playerDataManager.getPlayer(player),category.getName(),discoveryName)); + } commonItem.setLore(newLore); commonItemManager.replaceVariables(commonItem,variables,player); @@ -316,4 +324,31 @@ public ItemStack setDiscovery(String discoveryName,InventoryPlayer inventoryPlay item = ItemUtils.setTagStringItem(plugin,item,"codex_discovery",discoveryName); return item; } + + private boolean isSeen(Player player,String categoryName,String discoveryName){ + if(plugin.getPlayerDataManager().hasDiscovery(player,categoryName,discoveryName)){ + return true; + } + AccountKnowledge knowledge = plugin.getCharacterJournals().knowledge(player); + return knowledge != null && knowledge.other(categoryName,discoveryName) != null; + } + + /** Hover lines saying which living characters can use the entry in character. */ + private List inCharacterLore(AccountKnowledge knowledge,PlayerData current,String categoryName,String discoveryName){ + FileConfiguration messages = plugin.getMessagesConfig(); + List lines = new ArrayList<>(); + lines.add(""); + List names = knowledge.knownInCharacterBy(current,categoryName,discoveryName); + if(names.isEmpty()){ + lines.add(messages.getString("knownInCharacterByNobody",MessagesConfigManager.KNOWN_BY_NOBODY)); + }else{ + lines.add(messages.getString("knownInCharacterBy",MessagesConfigManager.KNOWN_BY) + .replace("%characters%",OtherUtils.joinNames(names))); + } + if(current == null || !current.hasDiscovery(categoryName,discoveryName)){ + lines.add(messages.getString("knownOutOfCharacterOnly",MessagesConfigManager.OUT_OF_CHARACTER_ONLY) + .replace("%character%",knowledge.characterName())); + } + return lines; + } } diff --git a/src/main/java/net/tfminecraft/codex/managers/PlayerDataManager.java b/src/main/java/net/tfminecraft/codex/managers/PlayerDataManager.java index 7e44ca4..4e2adf4 100644 --- a/src/main/java/net/tfminecraft/codex/managers/PlayerDataManager.java +++ b/src/main/java/net/tfminecraft/codex/managers/PlayerDataManager.java @@ -236,6 +236,7 @@ public void resetDataForAllPlayers(String categoryName, String discoveryName, Fi result = messagesConfig.getString("commandResetDiscoveryForAllPlayers").replace("%category%",categoryName) .replace("%discovery%",discoveryName); } + plugin.getCharacterJournals().refreshKnowledge(); callback.onDone(result); } } diff --git a/src/main/java/net/tfminecraft/codex/utils/OtherUtils.java b/src/main/java/net/tfminecraft/codex/utils/OtherUtils.java index 3d1edac..178acaf 100644 --- a/src/main/java/net/tfminecraft/codex/utils/OtherUtils.java +++ b/src/main/java/net/tfminecraft/codex/utils/OtherUtils.java @@ -1,5 +1,7 @@ package net.tfminecraft.codex.utils; +import java.util.List; + import net.tfminecraft.codex.Codex; import net.tfminecraft.codex.config.MainConfigManager; import me.clip.placeholderapi.PlaceholderAPI; @@ -94,4 +96,12 @@ public static String getDate(String format){ DateTimeFormatter formatter = DateTimeFormatter.ofPattern(format); return currentDate.format(formatter); } + + /** "A", "A and B", "A, B and C". */ + public static String joinNames(List names){ + if(names.size() == 1){ + return names.get(0); + } + return String.join(", ",names.subList(0,names.size()-1))+" and "+names.get(names.size()-1); + } } diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index 694b7fb..352571e 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -25,6 +25,9 @@ playerOpenInventory: "&aInventory &7%inventory% &aopened for &e%player%&a!" commandDoesNotExist: "&cThat command doesn't exist." pluginCriticalErrors: "&cThe plugin has detected some errors. Check them using &7/codex verify" clickActionsCooldown: "&cYou must wait &7%time% &cbefore using this item again." +knownInCharacterBy: "&7%characters% can use this in character." +knownInCharacterByNobody: "&7No living character can use this in character." +knownOutOfCharacterOnly: "&8&o%character% knows this only out of character." seconds: s minutes: m hours: h diff --git a/src/test/java/net/tfminecraft/codex/character/AccountKnowledgeTest.java b/src/test/java/net/tfminecraft/codex/character/AccountKnowledgeTest.java new file mode 100644 index 0000000..51ee2ad --- /dev/null +++ b/src/test/java/net/tfminecraft/codex/character/AccountKnowledgeTest.java @@ -0,0 +1,55 @@ +package net.tfminecraft.codex.character; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import net.tfminecraft.codex.model.data.PlayerData; +import net.tfminecraft.codex.model.data.PlayerDataCategory; +import net.tfminecraft.codex.model.data.PlayerDataDiscovery; + +class AccountKnowledgeTest { + @Test + void namesLivingCharactersWithThePlayedOneFirst() { + AccountKnowledge.Builder builder = new AccountKnowledge.Builder(); + builder.add(journal("aurea", "01/10/2026"), "Mira", true); + builder.add(journal("aurea", "05/10/2026"), "Old Tom", false); + builder.add(journal("aurea", "06/10/2026"), "Hazel", true); + AccountKnowledge knowledge = builder.build("Aldric"); + PlayerData current = new PlayerData(UUID.randomUUID(), UUID.randomUUID(), "Steve"); + current.addDiscovery("poi", "aurea", "14/10/2026"); + + assertEquals("Aldric", knowledge.characterName()); + assertEquals("01/10/2026", knowledge.other("poi", "aurea").date()); + assertEquals(List.of("Aldric", "Mira", "Hazel"), knowledge.knownInCharacterBy(current, "poi", "aurea")); + assertEquals(List.of("Mira", "Hazel"), knowledge.knownInCharacterBy(null, "poi", "aurea")); + } + + @Test + void entriesOnlyDeadCharactersFoundAreSeenButNamedByNobody() { + AccountKnowledge.Builder builder = new AccountKnowledge.Builder(); + builder.add(journal("vard", "01/10/2026"), "Old Tom", false); + AccountKnowledge knowledge = builder.build("Aldric"); + + assertEquals(List.of(), knowledge.other("poi", "vard").livingNames()); + assertEquals(List.of(), knowledge.knownInCharacterBy(null, "poi", "vard")); + } + + @Test + void anEmptyAccountKnowsNothingElse() { + AccountKnowledge knowledge = AccountKnowledge.empty("Aldric"); + + assertNull(knowledge.other("poi", "aurea")); + assertEquals(List.of(), knowledge.knownInCharacterBy(null, "poi", "aurea")); + } + + private static List journal(String discovery, String date) { + ArrayList found = new ArrayList<>(List.of(new PlayerDataDiscovery(discovery, date, 0))); + return List.of(new PlayerDataCategory("poi", false, found)); + } +} diff --git a/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java b/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java index c393956..6254d74 100644 --- a/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java +++ b/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java @@ -20,6 +20,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.logging.Handler; import java.util.logging.Level; @@ -42,10 +43,12 @@ import net.tfminecraft.codex.Codex; import net.tfminecraft.codex.managers.PlayerDataManager; import net.tfminecraft.codex.model.data.PlayerData; +import net.tfminecraft.tlibs.character.CharacterInfo; import net.tfminecraft.tlibs.character.CharacterRef; import net.tfminecraft.tlibs.character.CharacterSession; import net.tfminecraft.tlibs.character.CharacterSessionEndEvent; import net.tfminecraft.tlibs.character.CharacterSessionStartEvent; +import net.tfminecraft.tlibs.character.CharacterStatus; import net.tfminecraft.tlibs.character.Characters; class CharacterJournalsTest { @@ -300,6 +303,58 @@ void anEmptyOrUnlistableOldFolderIsHandled() throws IOException { assertLogged(Level.SEVERE, "Could not list pre-character Codex files"); } + @Test + void otherCharactersDiscoveriesStayReadable() throws IOException { + UUID dead = UUID.randomUUID(); + UUID unplayed = UUID.randomUUID(); + UUID broken = UUID.randomUUID(); + saved(second, "aurea"); + saved(dead, "vard"); + Files.writeString(store.characterFile(broken), "categories: [unclosed"); + characters.when(() -> Characters.characters(owner)).thenReturn(Optional.of(List.of( + info(first, "Aldric", CharacterStatus.ALIVE), info(second, "Mira", CharacterStatus.ALIVE), + info(dead, "Old Tom", CharacterStatus.DEAD), info(unplayed, "Hazel", CharacterStatus.ALIVE), + info(broken, "Bram", CharacterStatus.ALIVE)))); + + journals.onStart(start(first)); + + AccountKnowledge knowledge = journals.knowledge(player); + assertEquals("Aldric", knowledge.characterName()); + assertEquals(List.of("Mira"), knowledge.other("poi", "aurea").livingNames()); + assertEquals(List.of(), knowledge.other("poi", "vard").livingNames()); + assertLogged(Level.WARNING, "Codex for character " + broken + " could not be read"); + + journals.onEnd(end(first)); + assertNull(journals.knowledge(player)); + } + + @Test + void anUnknownRosterLeavesOnlyThePlayedCharacter() { + characters.when(() -> Characters.characters(owner)).thenReturn(Optional.empty()); + + journals.onStart(start(first)); + + assertEquals("Steve", journals.knowledge(player).characterName()); + } + + @Test + void refreshingRereadsOtherCharacters() throws IOException { + characters.when(() -> Characters.characters(owner)).thenReturn(Optional.of(List.of( + info(first, "Aldric", CharacterStatus.ALIVE), info(second, "Mira", CharacterStatus.ALIVE)))); + journals.onStart(start(first)); + assertNull(journals.knowledge(player).other("poi", "aurea")); + + saved(second, "aurea"); + journals.refreshKnowledge(); + + assertEquals(List.of("Mira"), journals.knowledge(player).other("poi", "aurea").livingNames()); + assertEquals("Aldric", journals.knowledge(player).characterName()); + } + + private CharacterInfo info(UUID character, String name, CharacterStatus status) { + return new CharacterInfo(new CharacterRef(owner, character), name, status, null, Set.of(), 0, null); + } + private CharacterSessionStartEvent start(UUID character) { return new CharacterSessionStartEvent(player, new CharacterSession(new CharacterRef(owner, character), 1)); } diff --git a/src/test/java/net/tfminecraft/codex/utils/OtherUtilsTest.java b/src/test/java/net/tfminecraft/codex/utils/OtherUtilsTest.java new file mode 100644 index 0000000..7f7ccb6 --- /dev/null +++ b/src/test/java/net/tfminecraft/codex/utils/OtherUtilsTest.java @@ -0,0 +1,16 @@ +package net.tfminecraft.codex.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +class OtherUtilsTest { + @Test + void joinsNamesForAHoverLine() { + assertEquals("Aldric", OtherUtils.joinNames(List.of("Aldric"))); + assertEquals("Aldric and Mira", OtherUtils.joinNames(List.of("Aldric", "Mira"))); + assertEquals("Aldric, Mira and Hazel", OtherUtils.joinNames(List.of("Aldric", "Mira", "Hazel"))); + } +} From fde6bfbaeae2b7c56d85fecf27d8078404fec87c Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:50:40 +0000 Subject: [PATCH 3/5] Address review: close gaps around out-of-character entries - Entries known only out of character run click actions only when they have no click cooldown, since the cooldown lives on the character's own entry. - /codex menus need a character in play; between characters they answer noCharacterInPlay and item actions close the menu. - /codex resetplayer * also resets account files not yet moved. - A reviewed heir deleted since the review falls through to the most-played rule; earlier players-migrated/ copies are never replaced. - Warn once if heirs are still unknown after 60 seconds; refresh the out-of-character view after the start-up move; a failed roster lookup falls back to the played character's own journal. - Depend only on TLibs: RPCharacters soft-depends on Codex, so depending on it back would make a load cycle. Co-Authored-By: Claude Opus 5.5 --- .../codex/character/CharacterJournals.java | 16 ++++++++++++++++ .../codex/character/CharacterStore.java | 8 ++++++-- .../net/tfminecraft/codex/character/Heirs.java | 9 ++++++--- .../codex/config/DataFolderConfigManager.java | 14 ++++++++++---- .../codex/config/MessagesConfigManager.java | 5 +++++ .../codex/config/PlayersConfigManager.java | 3 +++ .../codex/managers/InventoryManager.java | 17 +++++++++++++++-- src/main/resources/messages.yml | 1 + src/main/resources/plugin.yml | 2 +- .../codex/character/CharacterJournalsTest.java | 17 +++++++++++++++++ .../codex/character/CharacterStoreTest.java | 7 ++++++- .../tfminecraft/codex/character/HeirsTest.java | 18 ++++++++++++++---- 12 files changed, 100 insertions(+), 17 deletions(-) diff --git a/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java b/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java index ee4aca8..ac2e265 100644 --- a/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java +++ b/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java @@ -37,6 +37,7 @@ public final class CharacterJournals implements Listener { private final PlayerDataManager players; private final CharacterStore store; private final Logger logger; + static final int WARN_AFTER_SECONDS = 60; private final Map knowledge = new HashMap<>(); private BukkitTask sweep; @@ -51,10 +52,15 @@ public void start(Plugin plugin) { for (Player player : Bukkit.getOnlinePlayers()) { Characters.currentSession(player).ifPresent(session -> open(player, session.ref())); } + int[] waited = {0}; sweep = Bukkit.getScheduler().runTaskTimer(plugin, () -> { if (Characters.legacyHeirsReady()) { sweep.cancel(); migrateAll(); + refreshKnowledge(); + } else if (++waited[0] == WARN_AFTER_SECONDS) { + logger.warning("RPCharacters has not reported heirs after " + WARN_AFTER_SECONDS + " seconds; " + + "account Codex files in players/ wait until character bodies are enabled"); } }, 1L, 20L); } @@ -117,6 +123,16 @@ public void refreshKnowledge() { } private AccountKnowledge knowledgeOf(CharacterRef ref, String fallbackName) { + try { + return readKnowledge(ref, fallbackName); + } catch (RuntimeException ex) { + logger.log(Level.WARNING, "Could not read the other characters of account " + ref.ownerId() + + "; only this character's own Codex is shown", ex); + return AccountKnowledge.empty(fallbackName); + } + } + + private AccountKnowledge readKnowledge(CharacterRef ref, String fallbackName) { String name = fallbackName; AccountKnowledge.Builder builder = new AccountKnowledge.Builder(); for (CharacterInfo info : Characters.characters(ref.ownerId()).orElse(List.of())) { diff --git a/src/main/java/net/tfminecraft/codex/character/CharacterStore.java b/src/main/java/net/tfminecraft/codex/character/CharacterStore.java index 4cc558e..145d7bf 100644 --- a/src/main/java/net/tfminecraft/codex/character/CharacterStore.java +++ b/src/main/java/net/tfminecraft/codex/character/CharacterStore.java @@ -25,7 +25,7 @@ */ public final class CharacterStore { public static final String CHARACTERS = "characters"; - static final String LEGACY = "players"; + public static final String LEGACY = "players"; static final String MIGRATED = "players-migrated"; private final Path characters; @@ -107,7 +107,11 @@ public LegacyJournal readLegacy(UUID owner) throws IOException { /** Moves the account's old file aside once its discoveries belong to a character. */ public void retireLegacy(UUID owner) throws IOException { Files.createDirectories(migrated); - Files.move(legacyFile(owner), migrated.resolve(owner + ".yml"), StandardCopyOption.REPLACE_EXISTING); + Path target = migrated.resolve(owner + ".yml"); + for (int copy = 2; Files.exists(target); copy++) { + target = migrated.resolve(owner + "-" + copy + ".yml"); + } + Files.move(legacyFile(owner), target); } private Path legacyFile(UUID owner) { diff --git a/src/main/java/net/tfminecraft/codex/character/Heirs.java b/src/main/java/net/tfminecraft/codex/character/Heirs.java index e796b6b..63ea4be 100644 --- a/src/main/java/net/tfminecraft/codex/character/Heirs.java +++ b/src/main/java/net/tfminecraft/codex/character/Heirs.java @@ -29,13 +29,16 @@ static Optional of(UUID owner) { /** * The reviewed heir RPCharacters gives money and bodies to. Accounts that joined after that review - * have none, so their most-played living character inherits, else their most-played character. + * have none, and a reviewed heir can since have been deleted; then the most-played living character + * inherits, else the most-played character. */ static Optional choose(Optional reviewed, Optional> characters) { - if (reviewed.isPresent()) { + List all = characters.orElse(List.of()); + // A reviewed heir deleted since the review falls through to the rule below. + if (reviewed.isPresent() && (characters.isEmpty() + || all.stream().anyMatch(info -> info.ref().characterId().equals(reviewed.get().characterId())))) { return Optional.of(reviewed.get().characterId()); } - List all = characters.orElse(List.of()); Optional heir = all.stream().filter(CharacterInfo::alive).max(MOST_PLAYED); if (heir.isEmpty()) { heir = all.stream().max(MOST_PLAYED); diff --git a/src/main/java/net/tfminecraft/codex/config/DataFolderConfigManager.java b/src/main/java/net/tfminecraft/codex/config/DataFolderConfigManager.java index 7cc4f4e..ab14d4a 100644 --- a/src/main/java/net/tfminecraft/codex/config/DataFolderConfigManager.java +++ b/src/main/java/net/tfminecraft/codex/config/DataFolderConfigManager.java @@ -48,15 +48,21 @@ public CommonConfig getConfigFile(String pathName,boolean create) { } public ArrayList getConfigs(){ + return getConfigs(folderName); + } + + /** Every file in a folder of the data folder; none if the folder is missing. */ + public ArrayList getConfigs(String folder){ ArrayList configs = new ArrayList<>(); - String pathFile = plugin.getDataFolder() + File.separator + folderName; - File folder = new File(pathFile); - File[] listOfFiles = folder.listFiles(); + File[] listOfFiles = new File(plugin.getDataFolder() + File.separator + folder).listFiles(); + if(listOfFiles == null){ + return configs; + } for (File file : listOfFiles) { if (file.isFile()) { String pathName = file.getName(); - CommonConfig commonConfig = new CommonConfig(pathName, plugin, folderName, true); + CommonConfig commonConfig = new CommonConfig(pathName, plugin, folder, true); commonConfig.registerConfig(); configs.add(commonConfig); } diff --git a/src/main/java/net/tfminecraft/codex/config/MessagesConfigManager.java b/src/main/java/net/tfminecraft/codex/config/MessagesConfigManager.java index 79d9b50..5e3a97a 100644 --- a/src/main/java/net/tfminecraft/codex/config/MessagesConfigManager.java +++ b/src/main/java/net/tfminecraft/codex/config/MessagesConfigManager.java @@ -13,6 +13,7 @@ public class MessagesConfigManager { public static final String KNOWN_BY = "&7%characters% can use this in character."; public static final String KNOWN_BY_NOBODY = "&7No living character can use this in character."; public static final String OUT_OF_CHARACTER_ONLY = "&8&o%character% knows this only out of character."; + public static final String NO_CHARACTER_IN_PLAY = "&cChoose a character to open the Codex."; private Codex plugin; @@ -62,6 +63,10 @@ public void checkUpdates(){ config.set("knownOutOfCharacterOnly",OUT_OF_CHARACTER_ONLY); configFile.saveConfig(); } + if(!text.contains("noCharacterInPlay:")){ + config.set("noCharacterInPlay",NO_CHARACTER_IN_PLAY); + configFile.saveConfig(); + } if(!text.contains("clickActionsCooldown:")){ config.set("clickActionsCooldown","&cYou must wait &7%time% &cbefore using this item again."); config.set("seconds","s"); diff --git a/src/main/java/net/tfminecraft/codex/config/PlayersConfigManager.java b/src/main/java/net/tfminecraft/codex/config/PlayersConfigManager.java index cd2c3ad..e5d3865 100644 --- a/src/main/java/net/tfminecraft/codex/config/PlayersConfigManager.java +++ b/src/main/java/net/tfminecraft/codex/config/PlayersConfigManager.java @@ -1,6 +1,7 @@ package net.tfminecraft.codex.config; import net.tfminecraft.codex.Codex; +import net.tfminecraft.codex.character.CharacterStore; import org.bukkit.configuration.file.FileConfiguration; import java.io.File; @@ -23,8 +24,10 @@ public void saveConfigs(){ plugin.getCharacterJournals().saveAll(); } + /** Resets every character's file and every account file still waiting to move to its heir. */ public void resetDataForAllPlayers(String categoryName,String discoveryName){ ArrayList configs = getConfigs(); + configs.addAll(getConfigs(CharacterStore.LEGACY)); for(CommonConfig commonConfig : configs) { FileConfiguration config = commonConfig.getConfig(); diff --git a/src/main/java/net/tfminecraft/codex/managers/InventoryManager.java b/src/main/java/net/tfminecraft/codex/managers/InventoryManager.java index 247c760..203c619 100644 --- a/src/main/java/net/tfminecraft/codex/managers/InventoryManager.java +++ b/src/main/java/net/tfminecraft/codex/managers/InventoryManager.java @@ -72,6 +72,12 @@ public void removeInventoryPlayer(Player player){ } public void openInventory(InventoryPlayer inventoryPlayer){ + // The menus show the played character's journal, so there is nothing to show between characters. + if(!plugin.getPlayerDataManager().isLoaded(inventoryPlayer.getPlayer())){ + plugin.getMessagesManager().sendMessage(inventoryPlayer.getPlayer(), + plugin.getMessagesConfig().getString("noCharacterInPlay",MessagesConfigManager.NO_CHARACTER_IN_PLAY),true); + return; + } CommonInventory inventory = getInventory(inventoryPlayer.getInventoryName()); MainConfigManager mainConfigManager = plugin.getConfigsManager().getMainConfigManager(); @@ -151,8 +157,11 @@ private void clickOnDiscoveryItem(InventoryPlayer inventoryPlayer,String discove Player player = inventoryPlayer.getPlayer(); PlayerDataManager playerDataManager = plugin.getPlayerDataManager(); - // Entries any of the account's characters found stay readable, sub-pages included. - if(!isSeen(player,category.getName(),discoveryName)){ + // Entries any of the account's characters found stay readable, sub-pages included. Click actions + // with a cooldown can carry rewards, and the cooldown lives on the character's own entry, so + // those need the character's own discovery. + boolean ownDiscovery = playerDataManager.hasDiscovery(player,category.getName(),discoveryName); + if(!ownDiscovery && (!isSeen(player,category.getName(),discoveryName) || discovery.getClickActionsCooldown() != 0)){ return; } @@ -207,6 +216,10 @@ private void clickActionsItem(InventoryPlayer inventoryPlayer,String itemCommand } public void clickInventory(InventoryPlayer inventoryPlayer, ItemStack item, ClickType clickType){ + if(!plugin.getPlayerDataManager().isLoaded(inventoryPlayer.getPlayer())){ + inventoryPlayer.getPlayer().closeInventory(); + return; + } String itemActions = ItemUtils.getTagStringItem(plugin,item,"codex_item_actions"); if(itemActions != null){ clickActionsItem(inventoryPlayer,itemActions); diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index 352571e..cfbfabc 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -28,6 +28,7 @@ clickActionsCooldown: "&cYou must wait &7%time% &cbefore using this item again." knownInCharacterBy: "&7%characters% can use this in character." knownInCharacterByNobody: "&7No living character can use this in character." knownOutOfCharacterOnly: "&8&o%character% knows this only out of character." +noCharacterInPlay: "&cChoose a character to open the Codex." seconds: s minutes: m hours: h diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 24ce6dd..934935f 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -4,7 +4,7 @@ name: Codex api-version: '1.21.10' author: Ajneb97 depend: [TLibs] -softdepend: [RPCharacters,WorldGuard,WorldEdit,PlaceholderAPI,MythicMobs,Residence] +softdepend: [WorldGuard,WorldEdit,PlaceholderAPI,MythicMobs,Residence] commands: codex: diff --git a/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java b/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java index 6254d74..f19081a 100644 --- a/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java +++ b/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java @@ -266,6 +266,12 @@ void startOpensPlayedCharactersThenMovesOldFilesWhenHeirsAreKnown() throws IOExc verify(task, never()).cancel(); assertTrue(store.hasLegacy(owner)); + for (int second = 1; second < CharacterJournals.WARN_AFTER_SECONDS; second++) { + tick.getValue().run(); + } + assertLogged(Level.WARNING, "has not reported heirs after 60 seconds"); + tick.getValue().run(); + characters.when(Characters::legacyHeirsReady).thenReturn(true); tick.getValue().run(); } @@ -328,6 +334,17 @@ void otherCharactersDiscoveriesStayReadable() throws IOException { assertNull(journals.knowledge(player)); } + @Test + void aBrokenRosterLookupStillOpensTheJournal() { + characters.when(() -> Characters.characters(owner)).thenThrow(new IllegalStateException("boom")); + + journals.onStart(start(first)); + + assertNotNull(players.getPlayer(player)); + assertEquals("Steve", journals.knowledge(player).characterName()); + assertLogged(Level.WARNING, "Could not read the other characters of account " + owner); + } + @Test void anUnknownRosterLeavesOnlyThePlayedCharacter() { characters.when(() -> Characters.characters(owner)).thenReturn(Optional.empty()); diff --git a/src/test/java/net/tfminecraft/codex/character/CharacterStoreTest.java b/src/test/java/net/tfminecraft/codex/character/CharacterStoreTest.java index 8d5053d..ccbf8ca 100644 --- a/src/test/java/net/tfminecraft/codex/character/CharacterStoreTest.java +++ b/src/test/java/net/tfminecraft/codex/character/CharacterStoreTest.java @@ -144,7 +144,12 @@ void legacyJournalIsReadThenRetired() throws IOException { store.retireLegacy(owner); assertFalse(store.hasLegacy(owner)); - assertTrue(Files.readString(folder.resolve("players-migrated").resolve(owner + ".yml")).contains("Steve")); + assertTrue(Files.readString(folder.resolve("players-migrated").resolve(owner + ".yml")).contains("Older")); + assertTrue(Files.readString(folder.resolve("players-migrated").resolve(owner + "-2.yml")).contains("Steve")); + + write("players", owner, "name: Third\n"); + store.retireLegacy(owner); + assertTrue(Files.readString(folder.resolve("players-migrated").resolve(owner + "-3.yml")).contains("Third")); } @Test diff --git a/src/test/java/net/tfminecraft/codex/character/HeirsTest.java b/src/test/java/net/tfminecraft/codex/character/HeirsTest.java index 918fc41..50d8340 100644 --- a/src/test/java/net/tfminecraft/codex/character/HeirsTest.java +++ b/src/test/java/net/tfminecraft/codex/character/HeirsTest.java @@ -23,10 +23,20 @@ class HeirsTest { @Test void reviewedHeirWins() { CharacterInfo played = info(CharacterStatus.ALIVE, 9000, null); - UUID reviewed = UUID.randomUUID(); + CharacterInfo reviewed = info(CharacterStatus.ALIVE, 10, null); + + assertEquals(Optional.of(id(reviewed)), + Heirs.choose(Optional.of(reviewed.ref()), Optional.of(List.of(played, reviewed)))); + } + + @Test + void aReviewedHeirDeletedSinceFallsThroughToTheRoster() { + CharacterInfo left = info(CharacterStatus.ALIVE, 10, null); + UUID deleted = UUID.randomUUID(); - assertEquals(Optional.of(reviewed), - Heirs.choose(Optional.of(new CharacterRef(owner, reviewed)), Optional.of(List.of(played)))); + assertEquals(Optional.of(id(left)), + Heirs.choose(Optional.of(new CharacterRef(owner, deleted)), Optional.of(List.of(left)))); + assertEquals(Optional.of(deleted), Heirs.choose(Optional.of(new CharacterRef(owner, deleted)), Optional.empty())); } @Test @@ -63,7 +73,7 @@ void waitsUntilRpCharactersKnowsItsHeirs() { characters.when(Characters::legacyHeirsReady).thenReturn(true); characters.when(() -> Characters.legacyHeir(owner)).thenReturn(Optional.of(new CharacterRef(owner, reviewed))); - characters.when(() -> Characters.characters(owner)).thenReturn(Optional.of(List.of())); + characters.when(() -> Characters.characters(owner)).thenReturn(Optional.empty()); assertEquals(Optional.of(reviewed), Heirs.of(owner)); } } From 6d25296f7e1d1b8c9d6a63b143479036eb8e89cb Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:57:25 +0000 Subject: [PATCH 4/5] Start every character fresh instead of giving the heir old discoveries The old account journals don't record which character found an entry, so no character inherits them. players/.yml stays in place as the account's out-of-character reference: its entries show as readable with "No character can use this in character." until a character finds them again. This removes the heir lookup, the start-up move and players-migrated/. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- .../java/net/tfminecraft/codex/Codex.java | 2 +- .../codex/character/AccountKnowledge.java | 12 +- .../codex/character/CharacterJournals.java | 123 +++++----------- .../codex/character/CharacterStore.java | 41 +----- .../tfminecraft/codex/character/Heirs.java | 48 ------ .../codex/config/MessagesConfigManager.java | 2 +- .../codex/model/data/PlayerData.java | 28 ---- src/main/resources/messages.yml | 2 +- .../character/CharacterJournalsTest.java | 139 ++---------------- .../codex/character/CharacterStoreTest.java | 24 +-- .../codex/character/HeirsTest.java | 89 ----------- .../codex/model/data/PlayerDataTest.java | 32 ---- 13 files changed, 63 insertions(+), 482 deletions(-) delete mode 100644 src/main/java/net/tfminecraft/codex/character/Heirs.java delete mode 100644 src/test/java/net/tfminecraft/codex/character/HeirsTest.java delete mode 100644 src/test/java/net/tfminecraft/codex/model/data/PlayerDataTest.java diff --git a/README.md b/README.md index ac02f70..1694334 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ Each roleplay character keeps its own journal, so a new character discovers the world afresh and earns the discovery rewards again. An entry any of the account's characters has found stays readable for out-of-character reference, and its hover text names the living characters who can use it in character. What an account -found before this goes to the character that inherited its belongings. This copy tracks upstream +found before journals were per character stays readable this way, but no +character knows it in character until it finds it again. This copy tracks upstream 2.12.1, builds for Paper 1.21.10 and needs TLibs 2.6.1 with RPCharacters. It stores journals in files only, and has no update checker or bStats reporting. diff --git a/src/main/java/net/tfminecraft/codex/Codex.java b/src/main/java/net/tfminecraft/codex/Codex.java index f81342f..07df9e2 100644 --- a/src/main/java/net/tfminecraft/codex/Codex.java +++ b/src/main/java/net/tfminecraft/codex/Codex.java @@ -65,7 +65,7 @@ public void onEnable(){ characterJournals = new CharacterJournals(playerDataManager, new CharacterStore(getDataFolder().toPath()), getLogger()); registerEvents(); registerCommands(); - characterJournals.start(this); + characterJournals.start(); reloadPlayerDataSaveTask(); diff --git a/src/main/java/net/tfminecraft/codex/character/AccountKnowledge.java b/src/main/java/net/tfminecraft/codex/character/AccountKnowledge.java index 84ed4c7..fb249d4 100644 --- a/src/main/java/net/tfminecraft/codex/character/AccountKnowledge.java +++ b/src/main/java/net/tfminecraft/codex/character/AccountKnowledge.java @@ -10,13 +10,13 @@ import net.tfminecraft.codex.model.data.PlayerDataDiscovery; /** - * What the account's other characters have discovered, read when a character's session starts. - * An entry any character found, alive or dead, stays readable for out-of-character reference; - * only living characters can use it in character. The played character's own journal is live, - * so it is checked separately. + * What the account knows beyond the played character, read when a character's session starts: its + * other characters' journals and its journal from before characters had their own. Every such entry + * stays readable for out-of-character reference; only living characters who found it themselves can + * use it in character. The played character's own journal is live, so it is checked separately. */ public final class AccountKnowledge { - /** A discovery made by other characters: a date one of them found it, and which living ones know it. */ + /** A discovery known elsewhere on the account: a date it was found, and which living characters know it. */ public record Known(String date, List livingNames) { } @@ -58,7 +58,7 @@ static String key(String category, String discovery) { return category + ":" + discovery; } - /** Collects other characters' journals. Each is added once; dead characters count but are not named. */ + /** Collects journals. Each is added once; dead characters and the old account journal count but are not named. */ static final class Builder { private final Map dates = new HashMap<>(); private final Map> names = new HashMap<>(); diff --git a/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java b/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java index ac2e265..18ebf65 100644 --- a/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java +++ b/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java @@ -6,7 +6,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.UUID; import java.util.logging.Level; import java.util.logging.Logger; @@ -17,8 +16,6 @@ import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerQuitEvent; -import org.bukkit.plugin.Plugin; -import org.bukkit.scheduler.BukkitTask; import net.tfminecraft.codex.managers.PlayerDataManager; import net.tfminecraft.codex.model.data.PlayerData; @@ -31,15 +28,14 @@ /** * Gives each character its own journal: loaded when a character session starts and saved when it * ends. Between characters a player has no journal, so nothing can be discovered. Alongside it sits - * what the account's other characters know, so entries stay readable out of character. Main thread only. + * what the account knows from its other characters and from before journals were per character, so + * entries stay readable out of character. Main thread only. */ public final class CharacterJournals implements Listener { private final PlayerDataManager players; private final CharacterStore store; private final Logger logger; - static final int WARN_AFTER_SECONDS = 60; private final Map knowledge = new HashMap<>(); - private BukkitTask sweep; public CharacterJournals(PlayerDataManager players, CharacterStore store, Logger logger) { this.players = players; @@ -47,28 +43,14 @@ public CharacterJournals(PlayerDataManager players, CharacterStore store, Logger this.logger = logger; } - /** Opens journals for characters already in play and moves old account files once heirs are known. */ - public void start(Plugin plugin) { + /** Opens journals for characters already in play, for example when the plugin enables late. */ + public void start() { for (Player player : Bukkit.getOnlinePlayers()) { Characters.currentSession(player).ifPresent(session -> open(player, session.ref())); } - int[] waited = {0}; - sweep = Bukkit.getScheduler().runTaskTimer(plugin, () -> { - if (Characters.legacyHeirsReady()) { - sweep.cancel(); - migrateAll(); - refreshKnowledge(); - } else if (++waited[0] == WARN_AFTER_SECONDS) { - logger.warning("RPCharacters has not reported heirs after " + WARN_AFTER_SECONDS + " seconds; " - + "account Codex files in players/ wait until character bodies are enabled"); - } - }, 1L, 20L); } public void stop() { - if (sweep != null) { - sweep.cancel(); - } saveAll(); } @@ -89,7 +71,6 @@ public void onQuit(PlayerQuitEvent event) { void open(Player player, CharacterRef ref) { close(player, null); - migrate(ref.ownerId()); try { players.addPlayer(store.load(ref.ownerId(), ref.characterId(), player.getName())); knowledge.put(ref.ownerId(), knowledgeOf(ref, player.getName())); @@ -110,18 +91,35 @@ void close(Player player, UUID characterId) { knowledge.remove(player.getUniqueId()); } - /** What the player's other characters know, or null between characters. */ + /** What the player's account knows beyond the played character, or null between characters. */ public AccountKnowledge knowledge(Player player) { return knowledge.get(player.getUniqueId()); } - /** Rereads the other characters' journals, for example after staff reset them. */ + /** Rereads the other journals, for example after staff reset them. */ public void refreshKnowledge() { for (PlayerData data : players.getPlayers().values()) { knowledge.put(data.getUuid(), knowledgeOf(new CharacterRef(data.getUuid(), data.getCharacterId()), data.getName())); } } + public void saveAll() { + for (PlayerData data : new ArrayList<>(players.getPlayers().values())) { + save(data); + } + } + + private void save(PlayerData data) { + if (!data.isModified()) { + return; + } + try { + store.save(data); + } catch (IOException ex) { + logger.log(Level.SEVERE, "Could not save Codex for character " + data.getCharacterId(), ex); + } + } + private AccountKnowledge knowledgeOf(CharacterRef ref, String fallbackName) { try { return readKnowledge(ref, fallbackName); @@ -135,7 +133,17 @@ private AccountKnowledge knowledgeOf(CharacterRef ref, String fallbackName) { private AccountKnowledge readKnowledge(CharacterRef ref, String fallbackName) { String name = fallbackName; AccountKnowledge.Builder builder = new AccountKnowledge.Builder(); - for (CharacterInfo info : Characters.characters(ref.ownerId()).orElse(List.of())) { + // What the account found before journals were per character: readable, but no character knows it. + if (store.hasLegacy(ref.ownerId())) { + try { + builder.add(store.readLegacy(ref.ownerId()).categories(), null, false); + } catch (IOException ex) { + logger.log(Level.WARNING, "The account Codex of " + ref.ownerId() + " could not be read; " + + "its old discoveries are left out of its reference", ex); + } + } + List roster = Characters.characters(ref.ownerId()).orElse(List.of()); + for (CharacterInfo info : roster) { UUID id = info.ref().characterId(); if (id.equals(ref.characterId())) { name = info.name(); @@ -150,67 +158,4 @@ private AccountKnowledge readKnowledge(CharacterRef ref, String fallbackName) { } return builder.build(name); } - - public void saveAll() { - for (PlayerData data : new ArrayList<>(players.getPlayers().values())) { - save(data); - } - } - - private void save(PlayerData data) { - if (!data.isModified()) { - return; - } - try { - store.save(data); - } catch (IOException ex) { - logger.log(Level.SEVERE, "Could not save Codex for character " + data.getCharacterId(), ex); - } - } - - void migrateAll() { - List owners; - try { - owners = store.legacyOwners(); - } catch (IOException ex) { - logger.log(Level.SEVERE, "Could not list pre-character Codex files", ex); - return; - } - int moved = 0; - for (UUID owner : owners) { - if (migrate(owner)) { - moved++; - } - } - if (!owners.isEmpty()) { - logger.info("Moved " + moved + " of " + owners.size() + " account Codex files to characters; " - + "the rest move when their account has a character"); - } - } - - /** Merges the account's old discoveries into its heir, then sets the old file aside. */ - boolean migrate(UUID owner) { - if (!store.hasLegacy(owner)) { - return false; - } - Optional heir = Heirs.of(owner); - if (heir.isEmpty()) { - return false; - } - try { - CharacterStore.LegacyJournal legacy = store.readLegacy(owner); - PlayerData target = players.getPlayerByUUID(owner); - if (target == null || !heir.get().equals(target.getCharacterId())) { - target = store.load(owner, heir.get(), legacy.name()); - } - int added = target.absorb(legacy.categories()); - store.save(target); - store.retireLegacy(owner); - logger.info("Moved " + added + " Codex discoveries of account " + owner + " to character " + heir.get()); - return true; - } catch (IOException ex) { - logger.log(Level.SEVERE, "Could not move the Codex of account " + owner + " to its heir", ex); - return false; - } - } } diff --git a/src/main/java/net/tfminecraft/codex/character/CharacterStore.java b/src/main/java/net/tfminecraft/codex/character/CharacterStore.java index 145d7bf..44f9443 100644 --- a/src/main/java/net/tfminecraft/codex/character/CharacterStore.java +++ b/src/main/java/net/tfminecraft/codex/character/CharacterStore.java @@ -2,7 +2,6 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; @@ -19,26 +18,23 @@ import net.tfminecraft.codex.model.data.PlayerDataDiscovery; /** - * Discovery files. Each character has {@code characters/.yml}; {@code players/.yml} - * holds what an account found before characters had their own, until it moves to the account's heir - * and the old file goes to {@code players-migrated/}. Main thread only. + * Discovery files. Each character has {@code characters/.yml}. {@code players/.yml} + * holds what an account found before characters had their own; it stays as the account's + * out-of-character reference and no character inherits it. Main thread only. */ public final class CharacterStore { public static final String CHARACTERS = "characters"; public static final String LEGACY = "players"; - static final String MIGRATED = "players-migrated"; private final Path characters; private final Path legacy; - private final Path migrated; public CharacterStore(Path dataFolder) { this.characters = dataFolder.resolve(CHARACTERS); this.legacy = dataFolder.resolve(LEGACY); - this.migrated = dataFolder.resolve(MIGRATED); } - /** What an account found before characters, with the account name it was saved under. */ + /** What an account found before characters had their own journals, with the account name it was saved under. */ public record LegacyJournal(String name, List categories) { } @@ -76,25 +72,6 @@ public void save(PlayerData data) throws IOException { data.setModified(false); } - /** Accounts with a pre-character file still waiting to move. Files not named by a UUID are left alone. */ - public List legacyOwners() throws IOException { - List owners = new ArrayList<>(); - if (!Files.isDirectory(legacy)) { - return owners; - } - try (DirectoryStream files = Files.newDirectoryStream(legacy, "*.yml")) { - for (Path file : files) { - String stem = file.getFileName().toString(); - try { - owners.add(UUID.fromString(stem.substring(0, stem.length() - ".yml".length()))); - } catch (IllegalArgumentException notAccount) { - // Not an account file. - } - } - } - return owners; - } - public boolean hasLegacy(UUID owner) { return Files.isRegularFile(legacyFile(owner)); } @@ -104,16 +81,6 @@ public LegacyJournal readLegacy(UUID owner) throws IOException { return new LegacyJournal(yaml.getString("name"), categories(yaml)); } - /** Moves the account's old file aside once its discoveries belong to a character. */ - public void retireLegacy(UUID owner) throws IOException { - Files.createDirectories(migrated); - Path target = migrated.resolve(owner + ".yml"); - for (int copy = 2; Files.exists(target); copy++) { - target = migrated.resolve(owner + "-" + copy + ".yml"); - } - Files.move(legacyFile(owner), target); - } - private Path legacyFile(UUID owner) { return legacy.resolve(owner + ".yml"); } diff --git a/src/main/java/net/tfminecraft/codex/character/Heirs.java b/src/main/java/net/tfminecraft/codex/character/Heirs.java deleted file mode 100644 index 63ea4be..0000000 --- a/src/main/java/net/tfminecraft/codex/character/Heirs.java +++ /dev/null @@ -1,48 +0,0 @@ -package net.tfminecraft.codex.character; - -import java.time.Instant; -import java.util.Comparator; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -import net.tfminecraft.tlibs.character.CharacterInfo; -import net.tfminecraft.tlibs.character.CharacterRef; -import net.tfminecraft.tlibs.character.Characters; - -/** Which character inherits an account's pre-character discoveries. Main thread only. */ -final class Heirs { - private static final Comparator MOST_PLAYED = Comparator - .comparingLong(CharacterInfo::playtimeSeconds) - .thenComparing(info -> info.lastPlayedAt() == null ? Instant.MIN : info.lastPlayedAt()); - - private Heirs() { - } - - /** Empty until RPCharacters knows its heirs, and for accounts with no character yet. */ - static Optional of(UUID owner) { - if (!Characters.legacyHeirsReady()) { - return Optional.empty(); - } - return choose(Characters.legacyHeir(owner), Characters.characters(owner)); - } - - /** - * The reviewed heir RPCharacters gives money and bodies to. Accounts that joined after that review - * have none, and a reviewed heir can since have been deleted; then the most-played living character - * inherits, else the most-played character. - */ - static Optional choose(Optional reviewed, Optional> characters) { - List all = characters.orElse(List.of()); - // A reviewed heir deleted since the review falls through to the rule below. - if (reviewed.isPresent() && (characters.isEmpty() - || all.stream().anyMatch(info -> info.ref().characterId().equals(reviewed.get().characterId())))) { - return Optional.of(reviewed.get().characterId()); - } - Optional heir = all.stream().filter(CharacterInfo::alive).max(MOST_PLAYED); - if (heir.isEmpty()) { - heir = all.stream().max(MOST_PLAYED); - } - return heir.map(info -> info.ref().characterId()); - } -} diff --git a/src/main/java/net/tfminecraft/codex/config/MessagesConfigManager.java b/src/main/java/net/tfminecraft/codex/config/MessagesConfigManager.java index 5e3a97a..ff9a16c 100644 --- a/src/main/java/net/tfminecraft/codex/config/MessagesConfigManager.java +++ b/src/main/java/net/tfminecraft/codex/config/MessagesConfigManager.java @@ -11,7 +11,7 @@ public class MessagesConfigManager { public static final String KNOWN_BY = "&7%characters% can use this in character."; - public static final String KNOWN_BY_NOBODY = "&7No living character can use this in character."; + public static final String KNOWN_BY_NOBODY = "&7No character can use this in character."; public static final String OUT_OF_CHARACTER_ONLY = "&8&o%character% knows this only out of character."; public static final String NO_CHARACTER_IN_PLAY = "&cChoose a character to open the Codex."; diff --git a/src/main/java/net/tfminecraft/codex/model/data/PlayerData.java b/src/main/java/net/tfminecraft/codex/model/data/PlayerData.java index ac499bd..cbcb3fd 100644 --- a/src/main/java/net/tfminecraft/codex/model/data/PlayerData.java +++ b/src/main/java/net/tfminecraft/codex/model/data/PlayerData.java @@ -148,34 +148,6 @@ public long getMillisActionsExecuted(String category,String discovery){ return playerDataCategory.getMillisActionsExecuted(discovery); } - /** - * Adds discoveries this character lacks, keeping its own entries. A category counts as completed - * when either side completed it, so completion rewards are not paid twice. - * @return how many discoveries were added - */ - public int absorb(List others){ - int added = 0; - for(PlayerDataCategory other : others){ - PlayerDataCategory own = getCategory(other.getName()); - if(own == null){ - own = new PlayerDataCategory(other.getName(),false,new ArrayList<>()); - categories.add(own); - } - if(other.isCompleted()){ - own.setCompleted(true); - } - for(PlayerDataDiscovery discovery : other.getDiscoveries()){ - if(!own.hasDiscovery(discovery.getDiscoveryName())){ - own.addDiscovery(new PlayerDataDiscovery(discovery.getDiscoveryName(), - discovery.getDiscoverDate(),discovery.getMillisActionsExecuted())); - added++; - } - } - } - modified = true; - return added; - } - public void resetData(){ categories = new ArrayList<>(); modified = true; diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index cfbfabc..01c7dab 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -26,7 +26,7 @@ commandDoesNotExist: "&cThat command doesn't exist." pluginCriticalErrors: "&cThe plugin has detected some errors. Check them using &7/codex verify" clickActionsCooldown: "&cYou must wait &7%time% &cbefore using this item again." knownInCharacterBy: "&7%characters% can use this in character." -knownInCharacterByNobody: "&7No living character can use this in character." +knownInCharacterByNobody: "&7No character can use this in character." knownOutOfCharacterOnly: "&8&o%character% knows this only out of character." noCharacterInPlay: "&cChoose a character to open the Codex." seconds: s diff --git a/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java b/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java index f19081a..62f7461 100644 --- a/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java +++ b/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java @@ -4,14 +4,9 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.IOException; @@ -30,14 +25,10 @@ import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.bukkit.event.player.PlayerQuitEvent; -import org.bukkit.plugin.Plugin; -import org.bukkit.scheduler.BukkitScheduler; -import org.bukkit.scheduler.BukkitTask; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; import net.tfminecraft.codex.Codex; @@ -68,8 +59,6 @@ class CharacterJournalsTest { @BeforeEach void setUp() { characters = mockStatic(Characters.class); - characters.when(Characters::legacyHeirsReady).thenReturn(true); - characters.when(() -> Characters.legacyHeir(owner)).thenReturn(Optional.of(new CharacterRef(owner, first))); players = new PlayerDataManager(mock(Codex.class)); store = new CharacterStore(folder); Logger logger = Logger.getAnonymousLogger(); @@ -124,65 +113,27 @@ void switchingSavesTheOutgoingJournalFirst() throws IOException { } @Test - void theHeirInheritsTheAccountJournalOnItsFirstSession() throws IOException { + void theAccountJournalIsReferenceOnlyAndStaysInPlace() throws IOException { legacy("syrindell"); journals.onStart(start(first)); - assertTrue(players.getPlayer(player).hasDiscovery("poi", "syrindell")); - assertFalse(store.hasLegacy(owner)); - assertTrue(Files.exists(folder.resolve("players-migrated").resolve(owner + ".yml"))); - } - - @Test - void otherCharactersStartWithoutTheAccountJournal() throws IOException { - legacy("syrindell"); - - journals.onStart(start(second)); - - assertFalse(players.getPlayer(player).hasDiscovery("poi", "syrindell")); - assertTrue(store.load(owner, first, "Steve").hasDiscovery("poi", "syrindell")); - assertTrue(Files.readString(store.characterFile(first)).contains("name: Steve")); - } - - @Test - void anHeirAlreadyPlayingGetsTheDiscoveriesInMemory() throws IOException { - characters.when(Characters::legacyHeirsReady).thenReturn(false); - journals.onStart(start(first)); - PlayerData live = players.getPlayer(player); - live.addDiscovery("poi", "vard", "14/10/2026"); - legacy("syrindell"); - characters.when(Characters::legacyHeirsReady).thenReturn(true); - - assertTrue(journals.migrate(owner)); - - assertSame(live, players.getPlayer(player)); - assertTrue(live.hasDiscovery("poi", "syrindell")); - assertTrue(live.hasDiscovery("poi", "vard")); - assertTrue(store.load(owner, first, "Steve").hasDiscovery("poi", "vard")); - } - - @Test - void nothingMovesBeforeHeirsAreKnownOrWithoutAnOldFile() throws IOException { - assertFalse(journals.migrate(owner)); - - legacy("syrindell"); - characters.when(Characters::legacyHeirsReady).thenReturn(false); - journals.onStart(start(first)); - assertFalse(players.getPlayer(player).hasDiscovery("poi", "syrindell")); + AccountKnowledge.Known old = journals.knowledge(player).other("poi", "syrindell"); + assertEquals("24/09/2026", old.date()); + assertEquals(List.of(), old.livingNames()); assertTrue(store.hasLegacy(owner)); } @Test - void anUnreadableOldFileStaysForStaff() throws IOException { + void anUnreadableAccountJournalIsLeftOut() throws IOException { Files.createDirectories(folder.resolve("players")); Files.writeString(folder.resolve("players").resolve(owner + ".yml"), "name: [unclosed"); - assertFalse(journals.migrate(owner)); + journals.onStart(start(first)); - assertTrue(store.hasLegacy(owner)); - assertLogged(Level.SEVERE, "Could not move the Codex of account " + owner); + assertNotNull(players.getPlayer(player)); + assertLogged(Level.WARNING, "The account Codex of " + owner + " could not be read"); } @Test @@ -206,11 +157,12 @@ void endingASessionSavesOnlyThatCharacter() throws IOException { journals.onEnd(end(first)); assertNull(players.getPlayer(player)); + assertNull(journals.knowledge(player)); assertTrue(store.load(owner, first, "Steve").hasDiscovery("poi", "vard")); } @Test - void quittingSavesAndClosesAnyJournal() throws IOException { + void quittingSavesAndClosesAnyJournal() { journals.onQuit(new PlayerQuitEvent(player, "bye")); journals.onStart(start(first)); @@ -242,73 +194,20 @@ void aFailedSaveIsLogged() throws IOException { } @Test - void startOpensPlayedCharactersThenMovesOldFilesWhenHeirsAreKnown() throws IOException { - legacy("syrindell"); - UUID other = UUID.randomUUID(); - Files.writeString(folder.resolve("players").resolve(other + ".yml"), "name: Alex\n"); - characters.when(() -> Characters.characters(other)).thenReturn(Optional.of(List.of())); + void startOpensCharactersAlreadyInPlayAndStopSaves() { characters.when(() -> Characters.currentSession(player)) .thenReturn(Optional.of(new CharacterSession(new CharacterRef(owner, second), 1))); - characters.when(Characters::legacyHeirsReady).thenReturn(false); - Plugin plugin = mock(Plugin.class); - BukkitScheduler scheduler = mock(BukkitScheduler.class); - BukkitTask task = mock(BukkitTask.class); - ArgumentCaptor tick = ArgumentCaptor.forClass(Runnable.class); - when(scheduler.runTaskTimer(eq(plugin), tick.capture(), anyLong(), anyLong())).thenReturn(task); try (MockedStatic bukkit = mockStatic(Bukkit.class)) { bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(player)); - bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); - journals.start(plugin); - assertEquals(second, players.getPlayer(player).getCharacterId()); - - tick.getValue().run(); - verify(task, never()).cancel(); - assertTrue(store.hasLegacy(owner)); - - for (int second = 1; second < CharacterJournals.WARN_AFTER_SECONDS; second++) { - tick.getValue().run(); - } - assertLogged(Level.WARNING, "has not reported heirs after 60 seconds"); - tick.getValue().run(); - - characters.when(Characters::legacyHeirsReady).thenReturn(true); - tick.getValue().run(); + journals.start(); } - - verify(task).cancel(); - assertFalse(store.hasLegacy(owner)); - assertTrue(store.hasLegacy(other)); - assertLogged(Level.INFO, "Moved 1 of 2 account Codex files"); + assertEquals(second, players.getPlayer(player).getCharacterId()); journals.stop(); assertTrue(Files.exists(store.characterFile(second))); } - @Test - void stopWithoutStartStillSaves() { - journals.onStart(start(first)); - - journals.stop(); - - assertTrue(Files.exists(store.characterFile(first))); - } - - @Test - void anEmptyOrUnlistableOldFolderIsHandled() throws IOException { - journals.migrateAll(); - assertTrue(logs.isEmpty()); - - Path old = Files.createDirectories(folder.resolve("players")); - assertTrue(old.toFile().setReadable(false)); - try { - journals.migrateAll(); - } finally { - old.toFile().setReadable(true); - } - assertLogged(Level.SEVERE, "Could not list pre-character Codex files"); - } - @Test void otherCharactersDiscoveriesStayReadable() throws IOException { UUID dead = UUID.randomUUID(); @@ -329,9 +228,6 @@ void otherCharactersDiscoveriesStayReadable() throws IOException { assertEquals(List.of("Mira"), knowledge.other("poi", "aurea").livingNames()); assertEquals(List.of(), knowledge.other("poi", "vard").livingNames()); assertLogged(Level.WARNING, "Codex for character " + broken + " could not be read"); - - journals.onEnd(end(first)); - assertNull(journals.knowledge(player)); } @Test @@ -345,15 +241,6 @@ void aBrokenRosterLookupStillOpensTheJournal() { assertLogged(Level.WARNING, "Could not read the other characters of account " + owner); } - @Test - void anUnknownRosterLeavesOnlyThePlayedCharacter() { - characters.when(() -> Characters.characters(owner)).thenReturn(Optional.empty()); - - journals.onStart(start(first)); - - assertEquals("Steve", journals.knowledge(player).characterName()); - } - @Test void refreshingRereadsOtherCharacters() throws IOException { characters.when(() -> Characters.characters(owner)).thenReturn(Optional.of(List.of( diff --git a/src/test/java/net/tfminecraft/codex/character/CharacterStoreTest.java b/src/test/java/net/tfminecraft/codex/character/CharacterStoreTest.java index ccbf8ca..cba8ee5 100644 --- a/src/test/java/net/tfminecraft/codex/character/CharacterStoreTest.java +++ b/src/test/java/net/tfminecraft/codex/character/CharacterStoreTest.java @@ -111,18 +111,7 @@ void failedSaveKeepsTheJournalModified() throws IOException { } @Test - void legacyOwnersAreTheAccountFiles() throws IOException { - assertTrue(store.legacyOwners().isEmpty()); - - write("players", owner, "name: Steve\n"); - Files.writeString(folder.resolve("players").resolve("notes.yml"), "x: 1\n"); - Files.writeString(folder.resolve("players").resolve(UUID.randomUUID() + ".txt"), "x: 1\n"); - - assertEquals(List.of(owner), store.legacyOwners()); - } - - @Test - void legacyJournalIsReadThenRetired() throws IOException { + void legacyJournalIsReadInPlace() throws IOException { assertFalse(store.hasLegacy(owner)); write("players", owner, """ name: Steve @@ -132,7 +121,6 @@ void legacyJournalIsReadThenRetired() throws IOException { discoveries: - die;20/09/2026;0 """); - write("players-migrated", owner, "name: Older\n"); CharacterStore.LegacyJournal legacy = store.readLegacy(owner); @@ -140,16 +128,6 @@ void legacyJournalIsReadThenRetired() throws IOException { assertEquals("Steve", legacy.name()); assertTrue(legacy.categories().get(0).isCompleted()); assertTrue(legacy.categories().get(0).hasDiscovery("die")); - - store.retireLegacy(owner); - - assertFalse(store.hasLegacy(owner)); - assertTrue(Files.readString(folder.resolve("players-migrated").resolve(owner + ".yml")).contains("Older")); - assertTrue(Files.readString(folder.resolve("players-migrated").resolve(owner + "-2.yml")).contains("Steve")); - - write("players", owner, "name: Third\n"); - store.retireLegacy(owner); - assertTrue(Files.readString(folder.resolve("players-migrated").resolve(owner + "-3.yml")).contains("Third")); } @Test diff --git a/src/test/java/net/tfminecraft/codex/character/HeirsTest.java b/src/test/java/net/tfminecraft/codex/character/HeirsTest.java deleted file mode 100644 index 50d8340..0000000 --- a/src/test/java/net/tfminecraft/codex/character/HeirsTest.java +++ /dev/null @@ -1,89 +0,0 @@ -package net.tfminecraft.codex.character; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.mockStatic; - -import java.time.Instant; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; - -import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; - -import net.tfminecraft.tlibs.character.CharacterInfo; -import net.tfminecraft.tlibs.character.CharacterRef; -import net.tfminecraft.tlibs.character.CharacterStatus; -import net.tfminecraft.tlibs.character.Characters; - -class HeirsTest { - private final UUID owner = UUID.randomUUID(); - - @Test - void reviewedHeirWins() { - CharacterInfo played = info(CharacterStatus.ALIVE, 9000, null); - CharacterInfo reviewed = info(CharacterStatus.ALIVE, 10, null); - - assertEquals(Optional.of(id(reviewed)), - Heirs.choose(Optional.of(reviewed.ref()), Optional.of(List.of(played, reviewed)))); - } - - @Test - void aReviewedHeirDeletedSinceFallsThroughToTheRoster() { - CharacterInfo left = info(CharacterStatus.ALIVE, 10, null); - UUID deleted = UUID.randomUUID(); - - assertEquals(Optional.of(id(left)), - Heirs.choose(Optional.of(new CharacterRef(owner, deleted)), Optional.of(List.of(left)))); - assertEquals(Optional.of(deleted), Heirs.choose(Optional.of(new CharacterRef(owner, deleted)), Optional.empty())); - } - - @Test - void otherwiseTheMostPlayedLivingCharacter() { - CharacterInfo dead = info(CharacterStatus.DEAD, 9000, null); - CharacterInfo short1 = info(CharacterStatus.ALIVE, 100, Instant.parse("2026-10-01T00:00:00Z")); - CharacterInfo long1 = info(CharacterStatus.ALIVE, 500, null); - CharacterInfo long2 = info(CharacterStatus.ALIVE, 500, Instant.parse("2026-10-02T00:00:00Z")); - - assertEquals(Optional.of(id(long2)), - Heirs.choose(Optional.empty(), Optional.of(List.of(dead, short1, long1, long2)))); - } - - @Test - void anAllDeadAccountFallsBackToItsMostPlayed() { - CharacterInfo less = info(CharacterStatus.DEAD, 10, null); - CharacterInfo more = info(CharacterStatus.MISSING, 20, null); - - assertEquals(Optional.of(id(more)), Heirs.choose(Optional.empty(), Optional.of(List.of(less, more)))); - } - - @Test - void noCharacterMeansNoHeirYet() { - assertEquals(Optional.empty(), Heirs.choose(Optional.empty(), Optional.of(List.of()))); - assertEquals(Optional.empty(), Heirs.choose(Optional.empty(), Optional.empty())); - } - - @Test - void waitsUntilRpCharactersKnowsItsHeirs() { - UUID reviewed = UUID.randomUUID(); - try (MockedStatic characters = mockStatic(Characters.class)) { - characters.when(Characters::legacyHeirsReady).thenReturn(false); - assertEquals(Optional.empty(), Heirs.of(owner)); - - characters.when(Characters::legacyHeirsReady).thenReturn(true); - characters.when(() -> Characters.legacyHeir(owner)).thenReturn(Optional.of(new CharacterRef(owner, reviewed))); - characters.when(() -> Characters.characters(owner)).thenReturn(Optional.empty()); - assertEquals(Optional.of(reviewed), Heirs.of(owner)); - } - } - - private CharacterInfo info(CharacterStatus status, long playtime, Instant lastPlayed) { - return new CharacterInfo(new CharacterRef(owner, UUID.randomUUID()), "Name", status, null, Set.of(), - playtime, lastPlayed); - } - - private static UUID id(CharacterInfo info) { - return info.ref().characterId(); - } -} diff --git a/src/test/java/net/tfminecraft/codex/model/data/PlayerDataTest.java b/src/test/java/net/tfminecraft/codex/model/data/PlayerDataTest.java deleted file mode 100644 index f12d5aa..0000000 --- a/src/test/java/net/tfminecraft/codex/model/data/PlayerDataTest.java +++ /dev/null @@ -1,32 +0,0 @@ -package net.tfminecraft.codex.model.data; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; - -import org.junit.jupiter.api.Test; - -class PlayerDataTest { - @Test - void absorbAddsOnlyMissingDiscoveriesAndKeepsCompletion() { - PlayerData own = new PlayerData(UUID.randomUUID(), UUID.randomUUID(), "Steve"); - own.addDiscovery("poi", "vard", "14/10/2026"); - ArrayList poi = new ArrayList<>(List.of( - new PlayerDataDiscovery("vard", "01/09/2026", 5), - new PlayerDataDiscovery("aurea", "02/09/2026", 7))); - ArrayList achievements = new ArrayList<>(List.of(new PlayerDataDiscovery("die", "03/09/2026", 0))); - - int added = own.absorb(List.of(new PlayerDataCategory("poi", false, poi), - new PlayerDataCategory("achievements", true, achievements))); - - assertEquals(2, added); - assertTrue(own.isModified()); - assertEquals("14/10/2026", own.getDiscovery("poi", "vard").getDiscoverDate()); - assertEquals(7, own.getDiscovery("poi", "aurea").getMillisActionsExecuted()); - assertTrue(own.isCategoryCompleted("achievements")); - assertEquals(false, own.isCategoryCompleted("poi")); - } -} From f4649507f731b9dee6e8fb1f5ad78b15a89a6f28 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:06:30 +0000 Subject: [PATCH 5/5] Address review: keep journals whose save failed A journal that fails to save when its session ends stays in memory under its character, is retried with every save, is reused if the character returns first, and counts in the account's out-of-character reference. Staff resets apply to it too. Co-Authored-By: Claude Opus 5.5 --- .../codex/character/CharacterJournals.java | 27 ++++++++++++++---- .../codex/config/PlayersConfigManager.java | 2 +- .../codex/managers/PlayerDataManager.java | 8 ++++-- .../character/CharacterJournalsTest.java | 28 +++++++++++++++++++ 4 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java b/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java index 18ebf65..72cf26c 100644 --- a/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java +++ b/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java @@ -36,6 +36,8 @@ public final class CharacterJournals implements Listener { private final CharacterStore store; private final Logger logger; private final Map knowledge = new HashMap<>(); + /** Journals of characters no longer in play whose last save failed, by character; retried with every save. */ + private final Map unsaved = new HashMap<>(); public CharacterJournals(PlayerDataManager players, CharacterStore store, Logger logger) { this.players = players; @@ -72,7 +74,8 @@ public void onQuit(PlayerQuitEvent event) { void open(Player player, CharacterRef ref) { close(player, null); try { - players.addPlayer(store.load(ref.ownerId(), ref.characterId(), player.getName())); + PlayerData pending = unsaved.remove(ref.characterId()); + players.addPlayer(pending != null ? pending : store.load(ref.ownerId(), ref.characterId(), player.getName())); knowledge.put(ref.ownerId(), knowledgeOf(ref, player.getName())); } catch (IOException ex) { logger.log(Level.SEVERE, "Codex for character " + ref.characterId() + " of " + player.getName() @@ -86,11 +89,18 @@ void close(Player player, UUID characterId) { if (data == null || characterId != null && !characterId.equals(data.getCharacterId())) { return; } - save(data); + if (!save(data)) { + unsaved.put(data.getCharacterId(), data); + } players.removePlayer(data); knowledge.remove(player.getUniqueId()); } + /** Journals of characters out of play that still wait to be saved; staff resets apply to them too. */ + public List unsavedJournals() { + return List.copyOf(unsaved.values()); + } + /** What the player's account knows beyond the played character, or null between characters. */ public AccountKnowledge knowledge(Player player) { return knowledge.get(player.getUniqueId()); @@ -107,16 +117,21 @@ public void saveAll() { for (PlayerData data : new ArrayList<>(players.getPlayers().values())) { save(data); } + unsaved.values().removeIf(this::save); } - private void save(PlayerData data) { + /** Whether the journal is on disk; a failed save leaves it modified. */ + private boolean save(PlayerData data) { if (!data.isModified()) { - return; + return true; } try { store.save(data); + return true; } catch (IOException ex) { - logger.log(Level.SEVERE, "Could not save Codex for character " + data.getCharacterId(), ex); + logger.log(Level.SEVERE, "Could not save Codex for character " + data.getCharacterId() + + "; keeping it in memory and retrying with the next save", ex); + return false; } } @@ -147,6 +162,8 @@ private AccountKnowledge readKnowledge(CharacterRef ref, String fallbackName) { UUID id = info.ref().characterId(); if (id.equals(ref.characterId())) { name = info.name(); + } else if (unsaved.containsKey(id)) { + builder.add(unsaved.get(id).getCategories(), info.name(), info.alive()); } else if (Files.exists(store.characterFile(id))) { try { builder.add(store.load(ref.ownerId(), id, info.name()).getCategories(), info.name(), info.alive()); diff --git a/src/main/java/net/tfminecraft/codex/config/PlayersConfigManager.java b/src/main/java/net/tfminecraft/codex/config/PlayersConfigManager.java index e5d3865..6c88736 100644 --- a/src/main/java/net/tfminecraft/codex/config/PlayersConfigManager.java +++ b/src/main/java/net/tfminecraft/codex/config/PlayersConfigManager.java @@ -24,7 +24,7 @@ public void saveConfigs(){ plugin.getCharacterJournals().saveAll(); } - /** Resets every character's file and every account file still waiting to move to its heir. */ + /** Resets every character's file and every account's file from before journals were per character. */ public void resetDataForAllPlayers(String categoryName,String discoveryName){ ArrayList configs = getConfigs(); configs.addAll(getConfigs(CharacterStore.LEGACY)); diff --git a/src/main/java/net/tfminecraft/codex/managers/PlayerDataManager.java b/src/main/java/net/tfminecraft/codex/managers/PlayerDataManager.java index 4e2adf4..2653b17 100644 --- a/src/main/java/net/tfminecraft/codex/managers/PlayerDataManager.java +++ b/src/main/java/net/tfminecraft/codex/managers/PlayerDataManager.java @@ -224,15 +224,17 @@ public void resetDataForAllPlayers(String categoryName, String discoveryName, Fi // Offline characters are reset in their files, played characters in memory and saved later. plugin.getConfigsManager().getPlayersConfigManager().resetDataForAllPlayers(categoryName,discoveryName); + List journals = new ArrayList<>(players.values()); + journals.addAll(plugin.getCharacterJournals().unsavedJournals()); String result; if(categoryName == null){ - players.values().forEach(PlayerData::resetData); + journals.forEach(PlayerData::resetData); result = messagesConfig.getString("commandResetAllForAllPlayers"); }else if(discoveryName == null){ - players.values().forEach(p -> p.resetCategory(categoryName)); + journals.forEach(p -> p.resetCategory(categoryName)); result = messagesConfig.getString("commandResetCategoryForAllPlayers").replace("%category%",categoryName); }else{ - players.values().forEach(p -> p.resetDiscovery(categoryName,discoveryName)); + journals.forEach(p -> p.resetDiscovery(categoryName,discoveryName)); result = messagesConfig.getString("commandResetDiscoveryForAllPlayers").replace("%category%",categoryName) .replace("%discovery%",discoveryName); } diff --git a/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java b/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java index 62f7461..37febfe 100644 --- a/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java +++ b/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java @@ -193,6 +193,34 @@ void aFailedSaveIsLogged() throws IOException { assertLogged(Level.SEVERE, "Could not save Codex for character " + first); } + @Test + void aJournalThatFailedToSaveIsKeptUntilItSaves() throws IOException { + characters.when(() -> Characters.characters(owner)).thenReturn(Optional.of(List.of( + info(first, "Aldric", CharacterStatus.ALIVE), info(second, "Mira", CharacterStatus.ALIVE)))); + journals.onStart(start(first)); + players.getPlayer(player).addDiscovery("poi", "vard", "14/10/2026"); + Path characters = folder.resolve("characters"); + Files.writeString(characters, "not a folder"); + + journals.onStart(start(second)); + + assertEquals(second, players.getPlayer(player).getCharacterId()); + assertEquals(1, journals.unsavedJournals().size()); + assertEquals(List.of("Aldric"), journals.knowledge(player).other("poi", "vard").livingNames()); + journals.saveAll(); + assertEquals(1, journals.unsavedJournals().size()); + + journals.onStart(start(first)); + assertTrue(players.getPlayer(player).hasDiscovery("poi", "vard")); + assertEquals(List.of(second), journals.unsavedJournals().stream().map(PlayerData::getCharacterId).toList()); + + Files.delete(characters); + journals.saveAll(); + assertTrue(journals.unsavedJournals().isEmpty()); + assertTrue(store.load(owner, first, "Steve").hasDiscovery("poi", "vard")); + assertTrue(Files.exists(store.characterFile(second))); + } + @Test void startOpensCharactersAlreadyInPlayAndStopSaves() { characters.when(() -> Characters.currentSession(player))