diff --git a/README.md b/README.md index 2c3bac2..1694334 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,16 @@ 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. 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 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. ## Documentation @@ -16,7 +24,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..07df9e2 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(); 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/AccountKnowledge.java b/src/main/java/net/tfminecraft/codex/character/AccountKnowledge.java new file mode 100644 index 0000000..fb249d4 --- /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 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 known elsewhere on the account: a date it was found, and which living characters 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 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<>(); + + 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 new file mode 100644 index 0000000..72cf26c --- /dev/null +++ b/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java @@ -0,0 +1,178 @@ +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.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 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; +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. Alongside it sits + * 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; + 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; + this.store = store; + this.logger = logger; + } + + /** 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())); + } + } + + public void stop() { + 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); + try { + 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() + + " 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; + } + 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()); + } + + /** 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); + } + unsaved.values().removeIf(this::save); + } + + /** Whether the journal is on disk; a failed save leaves it modified. */ + private boolean save(PlayerData data) { + if (!data.isModified()) { + return true; + } + try { + store.save(data); + return true; + } catch (IOException 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; + } + } + + 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(); + // 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(); + } 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()); + } 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); + } +} 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..44f9443 --- /dev/null +++ b/src/main/java/net/tfminecraft/codex/character/CharacterStore.java @@ -0,0 +1,127 @@ +package net.tfminecraft.codex.character; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +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; 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"; + + private final Path characters; + private final Path legacy; + + public CharacterStore(Path dataFolder) { + this.characters = dataFolder.resolve(CHARACTERS); + this.legacy = dataFolder.resolve(LEGACY); + } + + /** 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) { + } + + 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); + } + + 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)); + } + + 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/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/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/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/MessagesConfigManager.java b/src/main/java/net/tfminecraft/codex/config/MessagesConfigManager.java index d63cc77..ff9a16c 100644 --- a/src/main/java/net/tfminecraft/codex/config/MessagesConfigManager.java +++ b/src/main/java/net/tfminecraft/codex/config/MessagesConfigManager.java @@ -10,6 +10,11 @@ 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 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; private CommonConfig configFile; @@ -52,6 +57,16 @@ 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("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 b551d9f..6c88736 100644 --- a/src/main/java/net/tfminecraft/codex/config/PlayersConfigManager.java +++ b/src/main/java/net/tfminecraft/codex/config/PlayersConfigManager.java @@ -1,19 +1,15 @@ 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 net.tfminecraft.codex.character.CharacterStore; 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,86 +19,15 @@ 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(); } + /** 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)); for(CommonConfig commonConfig : configs) { FileConfiguration config = commonConfig.getConfig(); 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/InventoryManager.java b/src/main/java/net/tfminecraft/codex/managers/InventoryManager.java index bfd3984..203c619 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; @@ -69,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(); @@ -148,9 +157,11 @@ 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. 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; } @@ -205,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); @@ -280,13 +295,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 +326,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 +337,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 b08a553..2653b17 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,44 @@ 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); + + List journals = new ArrayList<>(players.values()); + journals.addAll(plugin.getCharacterJournals().unsavedJournals()); + String result; + if(categoryName == null){ + journals.forEach(PlayerData::resetData); + result = messagesConfig.getString("commandResetAllForAllPlayers"); + }else if(discoveryName == null){ + journals.forEach(p -> p.resetCategory(categoryName)); + result = messagesConfig.getString("commandResetCategoryForAllPlayers").replace("%category%",categoryName); + }else{ + journals.forEach(p -> p.resetDiscovery(categoryName,discoveryName)); + 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/model/data/PlayerData.java b/src/main/java/net/tfminecraft/codex/model/data/PlayerData.java index e0c3286..cbcb3fd 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; } 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/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/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/messages.yml b/src/main/resources/messages.yml index 694b7fb..01c7dab 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -25,6 +25,10 @@ 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 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 4b02c4a..934935f 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -3,6 +3,7 @@ version: ${project.version} name: Codex api-version: '1.21.10' author: Ajneb97 +depend: [TLibs] softdepend: [WorldGuard,WorldEdit,PlaceholderAPI,MythicMobs,Residence] commands: 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 new file mode 100644 index 0000000..37febfe --- /dev/null +++ b/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java @@ -0,0 +1,321 @@ +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.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +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.Set; +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.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.MockedStatic; + +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 { + @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); + 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 theAccountJournalIsReferenceOnlyAndStaysInPlace() throws IOException { + legacy("syrindell"); + + 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 anUnreadableAccountJournalIsLeftOut() throws IOException { + Files.createDirectories(folder.resolve("players")); + Files.writeString(folder.resolve("players").resolve(owner + ".yml"), "name: [unclosed"); + + journals.onStart(start(first)); + + assertNotNull(players.getPlayer(player)); + assertLogged(Level.WARNING, "The account Codex of " + owner + " could not be read"); + } + + @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)); + assertNull(journals.knowledge(player)); + assertTrue(store.load(owner, first, "Steve").hasDiscovery("poi", "vard")); + } + + @Test + void quittingSavesAndClosesAnyJournal() { + 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 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)) + .thenReturn(Optional.of(new CharacterSession(new CharacterRef(owner, second), 1))); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(player)); + + journals.start(); + } + assertEquals(second, players.getPlayer(player).getCharacterId()); + + journals.stop(); + assertTrue(Files.exists(store.characterFile(second))); + } + + @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"); + } + + @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 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)); + } + + 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..cba8ee5 --- /dev/null +++ b/src/test/java/net/tfminecraft/codex/character/CharacterStoreTest.java @@ -0,0 +1,148 @@ +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 legacyJournalIsReadInPlace() throws IOException { + assertFalse(store.hasLegacy(owner)); + write("players", owner, """ + name: Steve + categories: + achievements: + completed: true + discoveries: + - die;20/09/2026;0 + """); + + 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")); + } + + @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/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"))); + } +}