From 85d9f93bc1319d644c9be9ce17f0e964980af83a Mon Sep 17 00:00:00 2001 From: Drefvelin <54400154+Drefvelin@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:56:12 +0000 Subject: [PATCH] Add Codex population placeholders from a cached snapshot. Placeholder lookups stay cheap because the holder counts refresh about once a minute instead of scanning Codex files on every request. Co-authored-by: Cursor --- README.md | 1 + .../rpcharacters/RPCharacters.java | 3 + .../placeholder/CodexLiveReader.java | 97 +++++ .../placeholder/CodexPopulation.java | 374 ++++++++++++++++++ .../placeholder/CodexPopulationService.java | 212 ++++++++++ .../placeholder/RpCharactersExpansion.java | 4 + src/main/resources/plugin.yml | 2 +- .../placeholder/CodexLiveReaderTest.java | 198 ++++++++++ .../CodexPopulationServiceTest.java | 327 +++++++++++++++ .../placeholder/CodexPopulationTest.java | 275 +++++++++++++ .../RpCharactersExpansionTest.java | 16 + 11 files changed, 1508 insertions(+), 1 deletion(-) create mode 100644 src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexLiveReader.java create mode 100644 src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulation.java create mode 100644 src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulationService.java create mode 100644 src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexLiveReaderTest.java create mode 100644 src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulationServiceTest.java create mode 100644 src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulationTest.java diff --git a/README.md b/README.md index 06d69d9b..4ba38eac 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Beyond a name and appearance, characters have traits, professions, injuries, and - **Consequences and investigation** — support lethal or nonlethal PvP, graves, and discoverable clues left in the world. - **Nonlethal knockouts** — GSit holds downed players in a crawl pose for the knockout duration, alongside freeze and blindness. The pose uses GSit's API, bypasses command restrictions, respects other plugins' crawl vetoes, and releases only knockout-created crawls on recovery. GSit is optional for the rest of RPCharacters; without it, knockouts retain freeze and blindness only. While downed, a player can only be finished by another player in lethal mode, a mob, `/kill`, the void or the world border: falls, drowning, fire and other damage with no attacker leave them at half a heart, and the fall distance built up by the freeze is cleared when they get up. Knockouts never apply to players in a started SimpleFactions battle, so the battle records the death and routes the respawn. - **PvP strikes** — after `/pvp start`, the fight lasts 15 minutes unless the player who started it runs `/pvp end`. When it ends, players who have not died see a title that a new RP interaction is needed. Whoever kills or knocks someone out chooses to spare them or give a strike; the third strike kills the character, though a killer can wound or maim instead of killing. The same player must wait 24 hours by default before striking a character again (`strikes.same-target-cooldown-hours` in `pvp.yml`; 0 disables the wait), so at that default they cannot land all three strikes in a day. Lockpicking, robbing, pickpocketing and looting locked graves start a timed evil RP session, during which any strike kills and a death leaves an unlocked grave. +- **Codex rarity** — `%rpcharacters_codex_percent_:%` is the nearest whole percent of Codex accounts that have unlocked that entry. `%rpcharacters_codex_holders_:%` is the count. An unknown category or entry is blank. The figure refreshes about once a minute from Codex's saved files, using live Codex data for players who are online. - **Armour takes time** — putting armour on takes a while (by default 1 second for a helmet, 20 for a chestplate, 12 for leggings and 8 for boots). Pieces go on one at a time and the player is slowed until they're done. Taking or dealing damage, sprinting, dying or logging out stops it. `/pvp start` takes off any chestplate, leggings or boots that someone in range put on in the last 3 minutes, and nobody in the fight can put armour on until it ends unless they die. Once recent armour is off, it notes who is still in a chestplate, leggings and boots: they keep their helmet and can take it off and put it back on for the rest of the fight. Anyone else also loses any helmet, mask or head they put on in the last 3 minutes, and can't put one on until the fight ends. This is decided when the fight is called and doesn't change during it. The times are set under `armour` in `pvp.yml`. Masks, heads and elytras go on at once. ## Beyond the game diff --git a/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java b/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java index d01e040c..c6cfb50e 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java +++ b/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java @@ -112,6 +112,7 @@ import net.tfminecraft.rpcharacters.chat.ChatRecipientResolverRegistry; import net.tfminecraft.rpcharacters.factions.RealmChatDependencyListener; import net.tfminecraft.rpcharacters.roll.RollManager; +import net.tfminecraft.rpcharacters.placeholder.CodexPopulationService; import net.tfminecraft.rpcharacters.placeholder.RpCharactersExpansion; import net.tfminecraft.rpcharacters.speechbubble.SpeechBubbleListener; import net.tfminecraft.rpcharacters.speechbubble.SpeechBubbleManager; @@ -281,6 +282,7 @@ public void onEnable() { } @Override public void onDisable() { + CodexPopulationService.stop(); if (focusModule != null) focusModule.shutdown(); ChatRecipientResolverRegistry.unregister( net.tfminecraft.rpcharacters.party.PartyManager.PARTY_RESOLVER_ID); @@ -759,6 +761,7 @@ public static int getFreeAttributePoints(Player player) { private void registerPlaceholderApi() { if (Bukkit.getPluginManager().isPluginEnabled("PlaceholderAPI")) { new RpCharactersExpansion().register(); + CodexPopulationService.start(this); getLogger().info("Registered PlaceholderAPI expansion: rpcharacters"); } else { getLogger().warning("PlaceholderAPI not found — %rpcharacters_*% placeholders will not work."); diff --git a/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexLiveReader.java b/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexLiveReader.java new file mode 100644 index 00000000..dd0ca0f3 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexLiveReader.java @@ -0,0 +1,97 @@ +package net.tfminecraft.rpcharacters.placeholder; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; + +/** + * Online Codex accounts, read from Codex's own player map. Codex drops a player + * from that map on quit, so this is only the live overlay on top of the saved files. + */ +final class CodexLiveReader { + private CodexLiveReader() {} + + static Map>> current() throws ReflectiveOperationException { + PluginManager plugins = Bukkit.getPluginManager(); + if (plugins == null) { + return Map.of(); + } + Plugin plugin = plugins.getPlugin("Codex"); + if (plugin == null || !plugin.isEnabled()) { + return Map.of(); + } + return read(plugin); + } + + static Map>> read(Object plugin) throws ReflectiveOperationException { + Object manager = plugin.getClass().getMethod("getPlayerDataManager").invoke(plugin); + if (manager == null) { + return Map.of(); + } + Object raw = manager.getClass().getMethod("getPlayers").invoke(manager); + if (!(raw instanceof Map players)) { + return Map.of(); + } + List values = new ArrayList<>(); + for (Object value : players.values()) { + values.add(value); + } + Map>> result = new HashMap<>(); + for (Object value : values) { + if (value == null) { + continue; + } + Object uuidRaw = value.getClass().getMethod("getUuid").invoke(value); + if (!(uuidRaw instanceof UUID uuid)) { + continue; + } + result.put(uuid, categoriesOf(value)); + } + return result; + } + + private static Map> categoriesOf(Object player) throws ReflectiveOperationException { + Object raw = player.getClass().getMethod("getCategories").invoke(player); + if (!(raw instanceof Iterable categories)) { + return Map.of(); + } + Map> byCategory = new HashMap<>(); + for (Object category : categories) { + if (category == null) { + continue; + } + Object nameRaw = category.getClass().getMethod("getName").invoke(category); + if (!(nameRaw instanceof String name) || name.isBlank()) { + continue; + } + Object discoveriesRaw = category.getClass().getMethod("getDiscoveries").invoke(category); + if (!(discoveriesRaw instanceof Iterable discoveries)) { + continue; + } + Set ids = new HashSet<>(); + for (Object discovery : discoveries) { + if (discovery == null) { + continue; + } + Object idRaw = discovery.getClass().getMethod("getDiscoveryName").invoke(discovery); + if (!(idRaw instanceof String id) || id.isBlank()) { + continue; + } + ids.add(id.trim().toLowerCase(Locale.ROOT)); + } + if (!ids.isEmpty()) { + byCategory.put(name.trim().toLowerCase(Locale.ROOT), Set.copyOf(ids)); + } + } + return byCategory; + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulation.java b/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulation.java new file mode 100644 index 00000000..6af48157 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulation.java @@ -0,0 +1,374 @@ +package net.tfminecraft.rpcharacters.placeholder; + +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.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.Consumer; + +/** + * Counts how many Codex accounts have each discovery. + * Codex 2.9 stores that in {@code plugins/Codex}: category ids are the category + * file names, and each player file lists discoveries as {@code id;date;millis}. + */ +final class CodexPopulation { + static final int MAX_FILE_BYTES = 1024 * 1024; + + private static final String PERCENT = "codex_percent_"; + private static final String HOLDERS = "codex_holders_"; + + private CodexPopulation() {} + + record Census(Set defined, Map>> players, int unreadable) { + static final Census EMPTY = new Census(Set.of(), Map.of(), 0); + } + + record Snapshot(Set defined, Map holders, int players) { + static final Snapshot EMPTY = new Snapshot(Set.of(), Map.of(), 0); + + String countText(boolean percent, String category, String discovery) { + String key = category + ":" + discovery; + if (!defined.contains(key)) { + return ""; + } + int count = holders.getOrDefault(key, 0); + if (!percent) { + return Integer.toString(count); + } + if (players <= 0) { + return "0"; + } + return Long.toString(Math.round(count * 100.0 / players)); + } + } + + @FunctionalInterface + interface LineReader { + List read(Path path) throws IOException; + } + + @FunctionalInterface + interface DirectoryLister { + List list(Path directory) throws IOException; + } + + static String answer(Snapshot snapshot, String params) { + if (params == null) { + return null; + } + Snapshot current = snapshot == null ? Snapshot.EMPTY : snapshot; + String lower = params.toLowerCase(Locale.ROOT); + boolean percent; + String rest; + if (lower.startsWith(PERCENT)) { + percent = true; + rest = params.substring(PERCENT.length()); + } else if (lower.startsWith(HOLDERS)) { + percent = false; + rest = params.substring(HOLDERS.length()); + } else { + return null; + } + int colon = rest.indexOf(':'); + if (colon <= 0 || colon >= rest.length() - 1) { + return ""; + } + String category = rest.substring(0, colon).trim().toLowerCase(Locale.ROOT); + String discovery = rest.substring(colon + 1).trim().toLowerCase(Locale.ROOT); + if (category.isEmpty() || discovery.isEmpty()) { + return ""; + } + return current.countText(percent, category, discovery); + } + + static Census read(Path codexFolder) { + return read(codexFolder, CodexPopulation::defaultLines, CodexPopulation::defaultList); + } + + static Census read(Path codexFolder, LineReader reader, DirectoryLister lister) { + if (codexFolder == null || !Files.isDirectory(codexFolder)) { + return Census.EMPTY; + } + int[] skipped = {0}; + Set defined = new HashSet<>(); + eachYaml(codexFolder.resolve("categories"), lister, skipped, path -> { + List lines = readLines(path, skipped, reader); + if (lines == null) { + return; + } + String name = fileStem(path); + if (name.isEmpty()) { + return; + } + defined.addAll(categoryDiscoveries(name, stripBom(lines))); + }); + Map>> players = new HashMap<>(); + eachYaml(codexFolder.resolve("players"), lister, skipped, path -> { + UUID uuid = playerId(path); + if (uuid == null) { + return; + } + List lines = readLines(path, skipped, reader); + if (lines == null) { + return; + } + players.put(uuid, parsePlayer(stripBom(lines))); + }); + return new Census(Set.copyOf(defined), copyPlayers(players), skipped[0]); + } + + static Snapshot combine(Census census, Map>> live) { + Census source = census == null ? Census.EMPTY : census; + Map>> merged = new HashMap<>(); + for (var entry : source.players().entrySet()) { + if (entry.getKey() != null) { + merged.put(entry.getKey(), entry.getValue()); + } + } + if (live != null) { + for (var entry : live.entrySet()) { + if (entry.getKey() == null) { + continue; + } + merged.put(entry.getKey(), entry.getValue() == null ? Map.of() : entry.getValue()); + } + } + Map counts = new HashMap<>(); + for (var unlocks : merged.values()) { + if (unlocks == null) { + continue; + } + for (var category : unlocks.entrySet()) { + if (category.getKey() == null || category.getValue() == null) { + continue; + } + String categoryName = category.getKey().trim().toLowerCase(Locale.ROOT); + for (String id : category.getValue()) { + if (id == null || id.isBlank()) { + continue; + } + String key = categoryName + ":" + id.trim().toLowerCase(Locale.ROOT); + if (!source.defined().contains(key)) { + continue; + } + counts.merge(key, 1, Integer::sum); + } + } + } + return new Snapshot(Set.copyOf(source.defined()), Map.copyOf(counts), merged.size()); + } + + static List defaultLines(Path path) throws IOException { + return Files.readAllLines(path, StandardCharsets.UTF_8); + } + + static List defaultList(Path directory) throws IOException { + List paths = new ArrayList<>(); + try (DirectoryStream stream = Files.newDirectoryStream(directory, "*.yml")) { + for (Path path : stream) { + paths.add(path); + } + } + return paths; + } + + private static void eachYaml(Path directory, DirectoryLister lister, int[] skipped, Consumer consumer) { + if (!Files.isDirectory(directory)) { + return; + } + List paths; + try { + paths = lister.list(directory); + } catch (IOException ex) { + skipped[0]++; + return; + } + for (Path path : paths) { + consumer.accept(path); + } + } + + private static List readLines(Path path, int[] skipped, LineReader reader) { + try { + if (!Files.isRegularFile(path)) { + skipped[0]++; + return null; + } + if (Files.size(path) > MAX_FILE_BYTES) { + skipped[0]++; + return null; + } + return reader.read(path); + } catch (IOException ex) { + skipped[0]++; + return null; + } + } + + private static List stripBom(List lines) { + if (lines.isEmpty()) { + return lines; + } + String first = lines.get(0); + if (!first.startsWith("\uFEFF")) { + return lines; + } + List copy = new ArrayList<>(lines); + copy.set(0, first.substring(1)); + return copy; + } + + private static String fileStem(Path path) { + String name = path.getFileName().toString(); + int dot = name.lastIndexOf('.'); + if (dot <= 0) { + return ""; + } + return name.substring(0, dot).toLowerCase(Locale.ROOT); + } + + private static UUID playerId(Path path) { + try { + return UUID.fromString(fileStem(path)); + } catch (IllegalArgumentException ex) { + return null; + } + } + + private static Set categoryDiscoveries(String category, List lines) { + Set found = new HashSet<>(); + boolean inDiscoveries = false; + for (String line : lines) { + if (!inDiscoveries) { + if (isDiscoveriesHeader(line)) { + inDiscoveries = true; + } + continue; + } + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + if (!line.isEmpty() && !Character.isWhitespace(line.charAt(0))) { + break; + } + String key = indentedKey(line); + if (key != null) { + found.add(category + ":" + key); + } + } + return found; + } + + private static boolean isDiscoveriesHeader(String line) { + if (line.startsWith(" ") || line.startsWith("\t")) { + return false; + } + int hash = line.indexOf('#'); + String body = (hash >= 0 ? line.substring(0, hash) : line).trim(); + return body.equals("discoveries:"); + } + + private static Map> parsePlayer(List lines) { + Map> byCategory = new HashMap<>(); + String category = null; + boolean inDiscoveries = false; + for (String line : lines) { + String key = indentedKey(line); + if (key != null) { + category = key; + inDiscoveries = false; + continue; + } + if (category != null && line.trim().equals("discoveries:")) { + inDiscoveries = true; + continue; + } + if (!inDiscoveries) { + continue; + } + String item = listItem(line); + if (item == null) { + if (!line.trim().startsWith("-")) { + inDiscoveries = false; + } + continue; + } + String id = discoveryId(item); + if (id.isEmpty()) { + continue; + } + byCategory.computeIfAbsent(category, ignored -> new HashSet<>()).add(id); + } + return byCategory; + } + + private static String indentedKey(String line) { + if (!line.startsWith(" ") || line.startsWith(" ")) { + return null; + } + String body = stripComment(line.substring(2)).trim(); + if (body.startsWith("-") || !body.endsWith(":")) { + return null; + } + String key = stripQuotes(body.substring(0, body.length() - 1).trim()); + if (key.isEmpty() || key.indexOf(' ') >= 0 || key.indexOf(':') >= 0) { + return null; + } + return key.toLowerCase(Locale.ROOT); + } + + private static String listItem(String line) { + String trimmed = line.trim(); + if (!trimmed.startsWith("- ")) { + return null; + } + return stripQuotes(stripComment(trimmed.substring(2)).trim()); + } + + private static String discoveryId(String item) { + int semi = item.indexOf(';'); + String id = semi < 0 ? item : item.substring(0, semi); + return stripQuotes(id.trim()).trim().toLowerCase(Locale.ROOT); + } + + private static String stripComment(String value) { + int hash = value.indexOf('#'); + if (hash >= 0) { + return value.substring(0, hash); + } + return value; + } + + private static String stripQuotes(String value) { + if (value.length() >= 2) { + char first = value.charAt(0); + char last = value.charAt(value.length() - 1); + if ((first == '"' && last == '"') || (first == '\'' && last == '\'')) { + return value.substring(1, value.length() - 1).trim(); + } + } + return value; + } + + private static Map>> copyPlayers(Map>> players) { + Map>> copy = new HashMap<>(); + for (var entry : players.entrySet()) { + Map> categories = new HashMap<>(); + for (var category : entry.getValue().entrySet()) { + categories.put(category.getKey(), Set.copyOf(category.getValue())); + } + copy.put(entry.getKey(), Map.copyOf(categories)); + } + return Map.copyOf(copy); + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulationService.java b/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulationService.java new file mode 100644 index 00000000..8b8f07cc --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulationService.java @@ -0,0 +1,212 @@ +package net.tfminecraft.rpcharacters.placeholder; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; + +import org.bukkit.Bukkit; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitTask; + +/** + * {@code %rpcharacters_codex_percent_:%} is the nearest whole + * percent of Codex accounts that have that entry. + * {@code %rpcharacters_codex_holders_:%} is the count. + * An unknown category or entry is blank. + *

+ * Lookups read an immutable snapshot. The snapshot is rebuilt off the main thread + * about once a minute from the Codex files, then the online players' live data + * replaces their saved files before it is published. + */ +public final class CodexPopulationService { + static final long REFRESH_PERIOD_TICKS = 20L * 60L; + + private static volatile CodexPopulationService active; + + private final JavaPlugin plugin; + private final Path codexFolder; + volatile CodexPopulation.Snapshot snapshot = CodexPopulation.Snapshot.EMPTY; + volatile boolean stopped; + private final AtomicBoolean scanning = new AtomicBoolean(); + private BukkitTask task; + private int reportedUnreadable = -1; + private boolean liveFailureLogged; + + Scanner scanner = CodexPopulation::read; + LiveReader liveReader = CodexLiveReader::current; + + @FunctionalInterface + interface Scanner { + CodexPopulation.Census scan(Path folder); + } + + @FunctionalInterface + interface LiveReader { + Map>> read() throws Exception; + } + + private CodexPopulationService(JavaPlugin plugin, Path codexFolder) { + this.plugin = plugin; + this.codexFolder = codexFolder; + } + + public static void start(JavaPlugin plugin) { + start(plugin, null, null); + } + + static void start(JavaPlugin plugin, Scanner scanner, LiveReader liveReader) { + stop(); + CodexPopulationService service = new CodexPopulationService(plugin, codexFolder(plugin)); + if (scanner != null) { + service.scanner = scanner; + } + if (liveReader != null) { + service.liveReader = liveReader; + } + active = service; + service.arm(); + } + + public static void stop() { + CodexPopulationService service = active; + active = null; + if (service != null) { + service.cancel(); + } + } + + static void replaceSnapshot(CodexPopulation.Snapshot snapshot) { + stop(); + CodexPopulationService service = new CodexPopulationService(null, null); + service.snapshot = snapshot; + service.stopped = true; + active = service; + } + + static CodexPopulationService running() { + return active; + } + + static String placeholder(String params) { + CodexPopulationService service = active; + CodexPopulation.Snapshot snapshot = service == null ? CodexPopulation.Snapshot.EMPTY : service.snapshot; + return CodexPopulation.answer(snapshot, params); + } + + private static Path codexFolder(JavaPlugin plugin) { + File data = plugin.getDataFolder(); + if (data == null) { + return null; + } + File parent = data.getParentFile(); + if (parent == null) { + return null; + } + return parent.toPath().resolve("Codex"); + } + + private void arm() { + if (codexFolder == null || !Files.isDirectory(codexFolder)) { + plugin.getLogger().info("Codex is not installed; discovery population placeholders are blank."); + } + submitRefresh(); + try { + task = Bukkit.getScheduler().runTaskTimerAsynchronously( + plugin, this::submitRefresh, REFRESH_PERIOD_TICKS, REFRESH_PERIOD_TICKS); + } catch (RuntimeException ex) { + plugin.getLogger().log(Level.WARNING, "Codex population placeholders will not refresh on a timer", ex); + } + } + + private void submitRefresh() { + if (stopped) { + return; + } + if (!scanning.compareAndSet(false, true)) { + return; + } + try { + Bukkit.getScheduler().runTaskAsynchronously(plugin, this::readDisk); + } catch (RuntimeException ex) { + scanning.set(false); + plugin.getLogger().log(Level.WARNING, "Codex population scan could not be scheduled", ex); + } + } + + private void readDisk() { + if (stopped) { + scanning.set(false); + return; + } + final CodexPopulation.Census census; + try { + census = scanner.scan(codexFolder); + } catch (RuntimeException ex) { + scanning.set(false); + plugin.getLogger().log(Level.WARNING, "Codex population scan failed", ex); + return; + } + if (stopped) { + scanning.set(false); + return; + } + try { + Bukkit.getScheduler().runTask(plugin, () -> publish(census)); + } catch (RuntimeException ex) { + scanning.set(false); + plugin.getLogger().log(Level.WARNING, "Codex population scan could not be published", ex); + } + } + + private void publish(CodexPopulation.Census census) { + try { + if (!stopped) { + snapshot = CodexPopulation.combine(census, safeLive()); + noteUnreadable(census.unreadable()); + } + } finally { + scanning.set(false); + } + } + + private Map>> safeLive() { + try { + Map>> live = liveReader.read(); + liveFailureLogged = false; + return live; + } catch (Exception ex) { + if (liveFailureLogged) { + return Map.of(); + } + liveFailureLogged = true; + plugin.getLogger().log(Level.WARNING, + "Could not read online Codex progress; population placeholders are using saved files", ex); + return Map.of(); + } + } + + private void noteUnreadable(int count) { + if (count == reportedUnreadable) { + return; + } + reportedUnreadable = count; + if (count <= 0) { + return; + } + plugin.getLogger().warning("Skipped " + count + " unreadable Codex file(s) while counting discoveries"); + } + + private void cancel() { + stopped = true; + BukkitTask current = task; + task = null; + if (current != null) { + current.cancel(); + } + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/placeholder/RpCharactersExpansion.java b/src/main/java/net/tfminecraft/rpcharacters/placeholder/RpCharactersExpansion.java index 8066690f..8174e909 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/placeholder/RpCharactersExpansion.java +++ b/src/main/java/net/tfminecraft/rpcharacters/placeholder/RpCharactersExpansion.java @@ -42,6 +42,10 @@ public String onRequest(OfflinePlayer offlinePlayer, String params) { if (params == null) { return null; } + String population = CodexPopulationService.placeholder(params); + if (population != null) { + return population; + } Player player = offlinePlayer != null ? offlinePlayer.getPlayer() : null; if (player == null) { return resolveOffline(params); diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 80c2c22f..c86d5c9e 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -5,7 +5,7 @@ version: ${project.version} author: Drefvelin depend: [TLibs, MMOCore, TFMCWeb, MMOItems, MythicLib] -softdepend: [ProtocolLib, PlaceholderAPI, LuckPerms, WorldGuard, SimpleFactions, GSit] +softdepend: [ProtocolLib, PlaceholderAPI, LuckPerms, WorldGuard, SimpleFactions, GSit, Codex] commands: class: diff --git a/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexLiveReaderTest.java b/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexLiveReaderTest.java new file mode 100644 index 00000000..512df921 --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexLiveReaderTest.java @@ -0,0 +1,198 @@ +package net.tfminecraft.rpcharacters.placeholder; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +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.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +class CodexLiveReaderTest { + @Test void absentOrDisabledCodexHasNoOnlinePlayers() throws Exception { + PluginManager manager = mock(PluginManager.class); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(null); + assertTrue(CodexLiveReader.current().isEmpty()); + bukkit.when(Bukkit::getPluginManager).thenReturn(manager); + assertTrue(CodexLiveReader.current().isEmpty()); + Plugin disabled = mock(Plugin.class); + when(manager.getPlugin("Codex")).thenReturn(disabled); + when(disabled.isEnabled()).thenReturn(false); + assertTrue(CodexLiveReader.current().isEmpty()); + when(disabled.isEnabled()).thenReturn(true); + assertThrows(ReflectiveOperationException.class, CodexLiveReader::current); + MemoryCodex codex = new MemoryCodex(); + UUID kept = UUID.randomUUID(); + codex.manager.players.put(kept, player(kept, List.of(category("research", List.of(discovery("Alchemy")))))); + when(manager.getPlugin("Codex")).thenReturn(codexPlugin(codex)); + Map>> live = CodexLiveReader.current(); + assertEquals(set("alchemy"), live.get(kept).get("research")); + } + } + + @Test void readsLiveDiscoveriesAndSkipsIncompleteRows() throws Exception { + UUID kept = UUID.randomUUID(); + UUID empty = UUID.randomUUID(); + MemoryCodex codex = new MemoryCodex(); + codex.manager.players.put(kept, player(kept, nullable( + category(" Research ", nullable(discovery(" Alchemy "), discovery("Alchemy"), discovery(" "), discovery(null), null)), + category(" ", List.of(discovery("alchemy"))), + category(7, List.of(discovery("alchemy"))), + null, + category("special", "not-a-list"), + category("empty", List.of(discovery(" ")))))); + codex.manager.players.put(empty, player(empty, null)); + codex.manager.players.put(UUID.randomUUID(), player("not-a-uuid", List.of())); + codex.manager.players.put(UUID.randomUUID(), null); + + Map>> live = CodexLiveReader.read(codex); + assertEquals(2, live.size()); + assertEquals(set("alchemy"), live.get(kept).get("research")); + assertTrue(live.get(empty).isEmpty()); + + codex.manager = null; + assertTrue(CodexLiveReader.read(codex).isEmpty()); + codex.manager = new MemoryCodex.Manager(); + codex.manager.rawPlayers = "not-a-map"; + assertTrue(CodexLiveReader.read(codex).isEmpty()); + assertThrows(ReflectiveOperationException.class, () -> CodexLiveReader.read(new Object())); + } + + @Test void utilityConstructorCanBeInvoked() throws Exception { + var ctor = CodexLiveReader.class.getDeclaredConstructor(); + ctor.setAccessible(true); + ctor.newInstance(); + } + + private interface CodexPlugin extends Plugin { + Object getPlayerDataManager(); + } + + private static Plugin codexPlugin(MemoryCodex codex) { + return (Plugin) Proxy.newProxyInstance(CodexLiveReaderTest.class.getClassLoader(), new Class[] { CodexPlugin.class }, + (proxy, method, args) -> switch (method.getName()) { + case "isEnabled" -> true; + case "getPlayerDataManager" -> codex.getPlayerDataManager(); + case "equals" -> proxy == args[0]; + case "hashCode" -> System.identityHashCode(proxy); + case "toString" -> "Codex"; + default -> fallback(method.getReturnType()); + }); + } + + private static Object fallback(Class type) { + if (!type.isPrimitive() || type == void.class) { + return null; + } + if (type == boolean.class) { + return false; + } + if (type == long.class) { + return 0L; + } + if (type == double.class) { + return 0d; + } + if (type == float.class) { + return 0f; + } + return 0; + } + + private static List nullable(Object... values) { + List list = new ArrayList<>(); + for (Object value : values) { + list.add(value); + } + return list; + } + + private static java.util.Set set(String value) { + return java.util.Set.of(value); + } + + private static MemoryCodex.MemoryPlayer player(Object uuid, Object categories) { + MemoryCodex.MemoryPlayer player = new MemoryCodex.MemoryPlayer(); + player.uuid = uuid; + player.categories = categories; + return player; + } + + private static MemoryCodex.MemoryCategory category(Object name, Object discoveries) { + MemoryCodex.MemoryCategory category = new MemoryCodex.MemoryCategory(); + category.name = name; + category.discoveries = discoveries; + return category; + } + + private static MemoryCodex.MemoryDiscovery discovery(Object name) { + MemoryCodex.MemoryDiscovery discovery = new MemoryCodex.MemoryDiscovery(); + discovery.discoveryName = name; + return discovery; + } + + static final class MemoryCodex { + Manager manager = new Manager(); + + public Manager getPlayerDataManager() { + return manager; + } + + static final class Manager { + Map players = new HashMap<>(); + Object rawPlayers = players; + + public Object getPlayers() { + return rawPlayers; + } + } + + static final class MemoryPlayer { + Object uuid; + Object categories; + + public Object getUuid() { + return uuid; + } + + public Object getCategories() { + return categories; + } + } + + static final class MemoryCategory { + Object name; + Object discoveries; + + public Object getName() { + return name; + } + + public Object getDiscoveries() { + return discoveries; + } + } + + static final class MemoryDiscovery { + Object discoveryName; + + public Object getDiscoveryName() { + return discoveryName; + } + } + } +} diff --git a/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulationServiceTest.java b/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulationServiceTest.java new file mode 100644 index 00000000..c318a83b --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulationServiceTest.java @@ -0,0 +1,327 @@ +package net.tfminecraft.rpcharacters.placeholder; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.File; +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.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +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.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockbukkit.mockbukkit.MockBukkit; + +class CodexPopulationServiceTest { + private static final UUID ALICE = UUID.fromString("00000000-0000-0000-0000-00000000000a"); + private static final UUID BOB = UUID.fromString("00000000-0000-0000-0000-00000000000b"); + private static final UUID CARA = UUID.fromString("00000000-0000-0000-0000-00000000000c"); + + JavaPlugin plugin; + Logger logger; + Level previousLevel; + Handler handler; + List messages; + BukkitScheduler scheduler; + PluginManager plugins; + MockedStatic bukkit; + List async = new ArrayList<>(); + List sync = new ArrayList<>(); + Runnable timer; + List timers = new ArrayList<>(); + boolean throwAsync; + boolean throwSync; + boolean throwTimer; + + @BeforeEach void setup() { + MockBukkit.mock(); + plugin = MockBukkit.createMockPlugin("RPCharacters"); + messages = new ArrayList<>(); + logger = plugin.getLogger(); + previousLevel = logger.getLevel(); + logger.setLevel(Level.ALL); + handler = new Handler() { + @Override public void publish(LogRecord record) { messages.add(record.getMessage()); } + @Override public void flush() {} + @Override public void close() {} + }; + logger.addHandler(handler); + scheduler = mock(BukkitScheduler.class); + plugins = mock(PluginManager.class); + bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + when(scheduler.runTaskAsynchronously(any(Plugin.class), any(Runnable.class))).thenAnswer(invocation -> { + if (throwAsync) { + throw new IllegalStateException("async off"); + } + async.add(invocation.getArgument(1)); + return mock(BukkitTask.class); + }); + when(scheduler.runTask(any(Plugin.class), any(Runnable.class))).thenAnswer(invocation -> { + if (throwSync) { + throw new IllegalStateException("sync off"); + } + sync.add(invocation.getArgument(1)); + return mock(BukkitTask.class); + }); + when(scheduler.runTaskTimerAsynchronously(any(Plugin.class), any(Runnable.class), anyLong(), anyLong())) + .thenAnswer(invocation -> { + if (throwTimer) { + throw new IllegalStateException("timer off"); + } + timer = invocation.getArgument(1); + BukkitTask task = mock(BukkitTask.class); + timers.add(task); + return task; + }); + } + + @AfterEach void cleanup() { + CodexPopulationService.stop(); + logger.removeHandler(handler); + logger.setLevel(previousLevel); + bukkit.close(); + MockBukkit.unmock(); + } + + @Test void refreshPublishesDiskCountsAndLivePlayersReplaceSavedFiles() throws Exception { + assertNull(CodexPopulationService.placeholder("name")); + assertEquals("", CodexPopulationService.placeholder("codex_percent_research:alchemy")); + assertEquals(20L * 60L, CodexPopulationService.REFRESH_PERIOD_TICKS); + writeSample(); + CodexPopulationService.start(plugin); + verify(scheduler).runTaskTimerAsynchronously( + eq(plugin), any(Runnable.class), eq(CodexPopulationService.REFRESH_PERIOD_TICKS), eq(CodexPopulationService.REFRESH_PERIOD_TICKS)); + drain(); + assertEquals("100", CodexPopulationService.placeholder("codex_percent_research:alchemy")); + assertEquals("0", CodexPopulationService.placeholder("codex_holders_research:bronze")); + assertEquals(0, count("Skipped")); + timer.run(); + drain(); + assertEquals(0, count("Skipped")); + + Map>> live = Map.of( + ALICE, Map.of("research", Set.of("bronze")), + CARA, Map.of("research", Set.of("alchemy"))); + CodexPopulationService.start(plugin, null, () -> live); + verify(timers.get(0)).cancel(); + drain(); + assertEquals("67", CodexPopulationService.placeholder("codex_percent_research:alchemy")); + assertEquals("2", CodexPopulationService.placeholder("codex_holders_research:alchemy")); + assertEquals("33", CodexPopulationService.placeholder("codex_percent_research:bronze")); + assertEquals("1", CodexPopulationService.placeholder("codex_holders_research:bronze")); + assertEquals("", CodexPopulationService.placeholder("codex_percent_research:missing")); + } + + @Test void failedScanKeepsThePreviousSnapshotAndCanRecover() throws Exception { + writeSample(); + CodexPopulationService.start(plugin, null, Map::of); + drain(); + assertEquals("100", CodexPopulationService.placeholder("codex_percent_research:alchemy")); + CodexPopulationService.running().scanner = path -> { + throw new IllegalStateException("boom"); + }; + timer.run(); + drain(); + assertEquals("100", CodexPopulationService.placeholder("codex_percent_research:alchemy")); + assertEquals(1, count("Codex population scan failed")); + CodexPopulationService.running().scanner = CodexPopulation::read; + timer.run(); + drain(); + assertEquals("100", CodexPopulationService.placeholder("codex_percent_research:alchemy")); + } + + @Test void liveReadFailureFallsBackToSavedFilesAndLogsOnceUntilItRecovers() throws Exception { + writeSample(); + AtomicBoolean fail = new AtomicBoolean(true); + Map>> live = Map.of( + ALICE, Map.of("research", Set.of("bronze")), + CARA, Map.of("research", Set.of("alchemy"))); + CodexPopulationService.start(plugin, null, () -> { + if (fail.get()) { + throw new IllegalStateException("down"); + } + return live; + }); + drain(); + assertEquals("100", CodexPopulationService.placeholder("codex_percent_research:alchemy")); + assertEquals(1, count("using saved files")); + timer.run(); + drain(); + assertEquals(1, count("using saved files")); + fail.set(false); + timer.run(); + drain(); + assertEquals("67", CodexPopulationService.placeholder("codex_percent_research:alchemy")); + fail.set(true); + timer.run(); + drain(); + assertEquals("100", CodexPopulationService.placeholder("codex_percent_research:alchemy")); + assertEquals(2, count("using saved files")); + } + + @Test void unreadableFilesAreReportedOnce() throws Exception { + writeSample(); + Files.createDirectory(codex().resolve("players").resolve(UUID.randomUUID() + ".yml")); + CodexPopulationService.start(plugin, null, Map::of); + drain(); + assertEquals(1, count("Skipped")); + assertTrue(messages.stream().anyMatch(message -> message.contains("Skipped 1 unreadable"))); + timer.run(); + drain(); + assertEquals(1, count("Skipped")); + } + + @Test void schedulingFailuresStayOnThePreviousSnapshot() throws Exception { + writeSample(); + throwAsync = true; + CodexPopulationService.start(plugin, null, Map::of); + assertEquals(1, count("could not be scheduled")); + throwAsync = false; + timer.run(); + drain(); + assertEquals("100", CodexPopulationService.placeholder("codex_percent_research:alchemy")); + + throwSync = true; + timer.run(); + drain(); + assertEquals(1, count("could not be published")); + assertEquals("100", CodexPopulationService.placeholder("codex_percent_research:alchemy")); + throwSync = false; + timer.run(); + drain(); + assertEquals("100", CodexPopulationService.placeholder("codex_percent_research:alchemy")); + + throwTimer = true; + assertDoesNotThrow(() -> CodexPopulationService.start(plugin, null, Map::of)); + assertEquals(1, count("will not refresh on a timer")); + throwTimer = false; + drain(); + assertEquals("100", CodexPopulationService.placeholder("codex_percent_research:alchemy")); + } + + @Test void aStoppedScanDoesNotPublishAndAnOverlappingScanIsIgnored() throws Exception { + writeSample(); + CodexPopulationService.start(plugin, null, Map::of); + assertEquals(1, async.size()); + timer.run(); + assertEquals(1, async.size()); + CodexPopulationService.stop(); + timer.run(); + drain(); + assertTrue(sync.isEmpty()); + assertNull(CodexPopulationService.running()); + + CodexPopulationService.start(plugin, path -> { + CodexPopulationService.stop(); + return CodexPopulation.read(path); + }, Map::of); + drain(); + assertTrue(sync.isEmpty()); + assertEquals("", CodexPopulationService.placeholder("codex_percent_research:alchemy")); + } + + @Test void publishAfterStopKeepsTheSnapshotAlreadyInMemory() throws Exception { + writeSample(); + CodexPopulationService.start(plugin, null, Map::of); + async.remove(0).run(); + CodexPopulation.Snapshot sentinel = new CodexPopulation.Snapshot( + Set.of("research:alchemy"), Map.of("research:alchemy", 1), 4); + CodexPopulationService.running().snapshot = sentinel; + CodexPopulationService.running().stopped = true; + sync.remove(0).run(); + assertEquals("25", CodexPopulationService.placeholder("codex_percent_research:alchemy")); + } + + @Test void missingCodexFolderAndDataFolderStayBlank() { + CodexPopulationService.stop(); + CodexPopulationService.start(plugin); + drain(); + assertEquals(1, count("Codex is not installed")); + assertEquals("", CodexPopulationService.placeholder("codex_holders_research:alchemy")); + + JavaPlugin bare = mock(JavaPlugin.class); + when(bare.getLogger()).thenReturn(logger); + when(bare.getDataFolder()).thenReturn(null); + CodexPopulationService.start(bare); + drain(); + when(bare.getDataFolder()).thenReturn(new File("RPCharacters")); + CodexPopulationService.start(bare); + drain(); + assertTrue(count("Codex is not installed") >= 2); + } + + private void drain() { + for (int guard = 0; guard < 8 && (!async.isEmpty() || !sync.isEmpty()); guard++) { + List asyncNow = List.copyOf(async); + async.clear(); + asyncNow.forEach(Runnable::run); + List syncNow = List.copyOf(sync); + sync.clear(); + syncNow.forEach(Runnable::run); + } + } + + private int count(String fragment) { + return (int) messages.stream().filter(message -> message.contains(fragment)).count(); + } + + private Path codex() { + return plugin.getDataFolder().getParentFile().toPath().resolve("Codex"); + } + + private void writeSample() throws IOException { + Path codex = codex(); + Files.createDirectories(codex.resolve("categories")); + Files.createDirectories(codex.resolve("players")); + write(codex.resolve("categories/research.yml"), + "discoveries:", + " alchemy:", + " name: Alchemy", + " bronze:", + " name: Bronze"); + write(codex.resolve("players/" + ALICE + ".yml"), + "categories:", + " research:", + " discoveries:", + " - alchemy;01/01/2026;0"); + write(codex.resolve("players/" + BOB + ".yml"), + "categories:", + " research:", + " discoveries:", + " - alchemy;01/01/2026;0"); + } + + private static void write(Path path, String... lines) throws IOException { + Files.writeString(path, String.join("\n", lines) + "\n"); + } +} diff --git a/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulationTest.java b/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulationTest.java new file mode 100644 index 00000000..b7c96bfc --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulationTest.java @@ -0,0 +1,275 @@ +package net.tfminecraft.rpcharacters.placeholder; + +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.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class CodexPopulationTest { + @TempDir Path temp; + + @Test void savedFilesMatchTheCodexLayoutAndIgnoreUnknownOrClosedLists() throws Exception { + Path codex = temp.resolve("Codex"); + Files.createDirectories(codex.resolve("categories")); + Files.createDirectories(codex.resolve("players")); + write(codex.resolve("categories/achievements.yml"), + "\uFEFFdiscoveries:", + " die:", + " name: Die", + "", + "# comment", + " poi_100:", + " name: Places"); + write(codex.resolve("categories/Research.yml"), + "discoveries:", + " alchemy: # science", + " name: Alchemy", + " \"bronze\":", + " name: Bronze", + " stray:", + " name: Stray", + "rewards:", + " secret:", + " name: Hidden"); + write(codex.resolve("categories/special.yml"), + "name: Special", + " not a discovery", + "discoveries:", + " the_lost_past:", + " name: Lost"); + UUID first = UUID.fromString("11111111-1111-1111-1111-111111111111"); + UUID second = UUID.fromString("22222222-2222-2222-2222-222222222222"); + write(codex.resolve("players/" + first + ".yml"), + "name: Example", + "categories:", + " my category:", + " note without colon", + " achievements:", + " completed: false", + " discoveries:", + " - die;20/09/2026;0", + " - die;21/09/2026;0", + " - \";01/01/2026;0\"", + " Research:", + " completed: false", + " discoveries:", + " - \"Alchemy;02/02/2026;0\"", + " completed: false", + " - stray;03/03/2026;0", + " poi:", + " completed: false", + " discoveries: []"); + Files.writeString(codex.resolve("players/" + second.toString().toUpperCase() + ".yml"), + "name: B\r\ncategories:\r\n achievements:\r\n discoveries:\r\n - die;01/01/2026;0\r\n"); + write(codex.resolve("players/notes.yml"), "name: not a player"); + + CodexPopulation.Census census = CodexPopulation.read(codex); + assertEquals(0, census.unreadable()); + assertEquals(2, census.players().size()); + assertTrue(census.defined().contains("research:alchemy")); + assertTrue(census.defined().contains("research:bronze")); + assertFalse(census.defined().contains("research:secret")); + CodexPopulation.Snapshot snapshot = CodexPopulation.combine(census, Map.of()); + assertEquals("2", CodexPopulation.answer(snapshot, "codex_holders_achievements:die")); + assertEquals("100", CodexPopulation.answer(snapshot, "codex_percent_achievements:die")); + assertEquals("1", CodexPopulation.answer(snapshot, "codex_holders_research:alchemy")); + assertEquals("50", CodexPopulation.answer(snapshot, "codex_percent_research:alchemy")); + assertEquals("0", CodexPopulation.answer(snapshot, "codex_holders_research:bronze")); + assertEquals("0", CodexPopulation.answer(snapshot, "codex_percent_research:stray")); + assertEquals("0", CodexPopulation.answer(snapshot, "codex_holders_achievements:poi_100")); + assertEquals("0", CodexPopulation.answer(snapshot, "codex_percent_special:the_lost_past")); + assertEquals("", CodexPopulation.answer(snapshot, "codex_percent_research:secret")); + assertEquals("", CodexPopulation.answer(snapshot, "codex_holders_research:nope")); + assertEquals(census.defined(), snapshot.defined()); + assertEquals(2, snapshot.players()); + } + + @Test void onlinePlayersReplaceTheirSavedFiles() { + UUID saved = UUID.randomUUID(); + UUID online = UUID.randomUUID(); + UUID extra = UUID.randomUUID(); + Set defined = Set.of("research:alchemy", "research:bronze"); + Map>> disk = new HashMap<>(); + disk.put(saved, Map.of("research", Set.of("alchemy"))); + disk.put(online, Map.of("Research", Set.of(" Alchemy "))); + disk.put(null, Map.of("research", Set.of("alchemy"))); + Map>> live = new HashMap<>(); + live.put(online, Map.of("research", Set.of("bronze"))); + live.put(extra, null); + live.put(null, Map.of("research", Set.of("alchemy"))); + CodexPopulation.Snapshot snapshot = CodexPopulation.combine(new CodexPopulation.Census(defined, disk, 0), live); + assertEquals(3, snapshot.players()); + assertEquals("1", CodexPopulation.answer(snapshot, "codex_holders_research:alchemy")); + assertEquals("1", CodexPopulation.answer(snapshot, "codex_holders_research:bronze")); + assertEquals("33", CodexPopulation.answer(snapshot, "codex_percent_research:alchemy")); + } + + @Test void blankAndUnknownDiscoveriesDoNotCount() { + UUID player = UUID.randomUUID(); + Map> unlocks = new HashMap<>(); + Set ids = new HashSet<>(); + ids.add(null); + ids.add(" "); + ids.add("nope"); + ids.add("alchemy"); + unlocks.put(null, Set.of("alchemy")); + unlocks.put("research", null); + unlocks.put("Research", ids); + Map>> disk = new HashMap<>(); + disk.put(player, null); + disk.put(UUID.randomUUID(), unlocks); + CodexPopulation.Snapshot snapshot = CodexPopulation.combine( + new CodexPopulation.Census(Set.of("research:alchemy"), disk, 0), null); + assertEquals(2, snapshot.players()); + assertEquals("1", CodexPopulation.answer(snapshot, "codex_holders_research:alchemy")); + assertEquals("", CodexPopulation.answer(snapshot, "codex_percent_research:nope")); + } + + @Test void percentRoundsToTheNearestWholeNumberAndZeroPopulationIsZero() { + assertEquals("1", percent(1, 199)); + assertEquals("0", percent(1, 200)); + assertEquals("33", percent(1, 2)); + assertEquals("67", percent(2, 1)); + assertEquals("50", percent(1, 1)); + assertEquals("0", percent(0, 2)); + CodexPopulation.Snapshot empty = CodexPopulation.combine( + new CodexPopulation.Census(Set.of("research:alchemy"), Map.of(), 0), Map.of()); + assertEquals("0", CodexPopulation.answer(empty, "codex_percent_research:alchemy")); + assertEquals("0", CodexPopulation.answer(empty, "codex_holders_research:alchemy")); + assertEquals(0, empty.holders().size()); + } + + @Test void placeholderTextRejectsMalformedNamesAndUnknownSnapshots() { + CodexPopulation.Snapshot snapshot = new CodexPopulation.Snapshot( + Set.of("research:alchemy"), Map.of("research:alchemy", 1), 2); + assertNull(CodexPopulation.answer(snapshot, null)); + assertNull(CodexPopulation.answer(snapshot, "name")); + assertNull(CodexPopulation.answer(snapshot, "codex_percent")); + assertNull(CodexPopulation.answer(snapshot, "codex_holders")); + assertEquals("", CodexPopulation.answer(snapshot, "codex_percent_research")); + assertEquals("", CodexPopulation.answer(snapshot, "codex_percent_:alchemy")); + assertEquals("", CodexPopulation.answer(snapshot, "codex_holders_research:")); + assertEquals("", CodexPopulation.answer(snapshot, "codex_percent_ :alchemy")); + assertEquals("", CodexPopulation.answer(snapshot, "codex_holders_research: ")); + assertEquals("", CodexPopulation.answer(null, "codex_percent_research:alchemy")); + assertEquals("", CodexPopulation.answer(CodexPopulation.Snapshot.EMPTY, "codex_percent_research:alchemy")); + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + assertEquals("50", CodexPopulation.answer(snapshot, "CODEX_PERCENT_RESEARCH:IGNITIUM".replace("IGNITIUM", "ALCHEMY"))); + assertEquals("1", CodexPopulation.answer(snapshot, "CODEX_HOLDERS_RESEARCH:ALCHEMY")); + } finally { + Locale.setDefault(previous); + } + } + + @Test void missingCorruptAndOversizedFilesAreSkipped() throws Exception { + assertEquals(0, CodexPopulation.read(null).players().size()); + Path file = temp.resolve("not-a-folder"); + Files.writeString(file, "x"); + assertEquals(0, CodexPopulation.read(file).defined().size()); + Path empty = temp.resolve("empty-codex"); + Files.createDirectories(empty); + assertEquals(0, CodexPopulation.read(empty).unreadable()); + + Path codex = temp.resolve("partial"); + Files.createDirectories(codex.resolve("categories")); + write(codex.resolve("categories/research.yml"), "discoveries:", " alchemy:", " name: Alchemy"); + CodexPopulation.Census categoriesOnly = CodexPopulation.read(codex); + assertEquals(1, categoriesOnly.defined().size()); + assertEquals(0, categoriesOnly.players().size()); + assertEquals("0", CodexPopulation.answer( + CodexPopulation.combine(categoriesOnly, Map.of()), "codex_percent_research:alchemy")); + Path blank = temp.resolve("blank-player"); + Files.createDirectories(blank.resolve("players")); + Files.writeString(blank.resolve("players/" + UUID.randomUUID() + ".yml"), ""); + CodexPopulation.Census blankCensus = CodexPopulation.read(blank); + assertEquals(1, blankCensus.players().size()); + assertTrue(blankCensus.players().values().iterator().next().isEmpty()); + + Path broken = temp.resolve("broken"); + Files.createDirectories(broken.resolve("categories")); + Files.createDirectories(broken.resolve("players")); + UUID valid = UUID.randomUUID(); + write(broken.resolve("players/" + valid + ".yml"), + "categories:", " research:", " discoveries:", " - alchemy;01/01/2026;0"); + write(broken.resolve("categories/research.yml"), "discoveries:", " alchemy:", " name: Alchemy"); + Files.write(broken.resolve("players/" + UUID.randomUUID() + ".yml"), new byte[CodexPopulation.MAX_FILE_BYTES + 1]); + Files.createDirectory(broken.resolve("players/" + UUID.randomUUID() + ".yml")); + write(broken.resolve("players/notes.yml"), "nope"); + CodexPopulation.Census skipped = CodexPopulation.read(broken); + assertEquals(2, skipped.unreadable()); + assertEquals(1, skipped.players().size()); + assertTrue(skipped.defined().contains("research:alchemy")); + + Path dot = broken.resolve("categories/.yml"); + Files.writeString(dot, "discoveries:\n ignored:\n"); + CodexPopulation.Census named = CodexPopulation.read(broken, CodexPopulation::defaultLines, dir -> { + if ("categories".equals(dir.getFileName().toString())) { + return List.of(dot); + } + return List.of(); + }); + assertTrue(named.defined().isEmpty()); + assertFalse(named.defined().contains("ignored")); + } + + @Test void unreadableDirectoriesAndFilesDoNotFailTheScan() throws Exception { + Path codex = temp.resolve("io"); + Files.createDirectories(codex.resolve("categories")); + Files.createDirectories(codex.resolve("players")); + write(codex.resolve("categories/research.yml"), "discoveries:", " alchemy:", " name: Alchemy"); + write(codex.resolve("players/" + UUID.randomUUID() + ".yml"), + "categories:", " research:", " discoveries:", " - alchemy;01/01/2026;0"); + CodexPopulation.Census lines = CodexPopulation.read(codex, path -> { + throw new IOException("disk"); + }, CodexPopulation::defaultList); + assertEquals(2, lines.unreadable()); + assertTrue(lines.defined().isEmpty()); + assertTrue(lines.players().isEmpty()); + + CodexPopulation.Census listed = CodexPopulation.read(codex, CodexPopulation::defaultLines, dir -> { + throw new IOException("list"); + }); + assertEquals(2, listed.unreadable()); + assertTrue(listed.defined().isEmpty()); + } + + @Test void utilityConstructorCanBeInvoked() throws Exception { + var ctor = CodexPopulation.class.getDeclaredConstructor(); + ctor.setAccessible(true); + ctor.newInstance(); + } + + private static String percent(int holders, int emptyPlayers) { + Map>> players = new HashMap<>(); + for (int i = 0; i < holders; i++) { + players.put(UUID.randomUUID(), Map.of("research", Set.of("alchemy"))); + } + for (int i = 0; i < emptyPlayers; i++) { + players.put(UUID.randomUUID(), Map.of()); + } + CodexPopulation.Snapshot snapshot = CodexPopulation.combine( + new CodexPopulation.Census(Set.of("research:alchemy"), players, 0), Map.of()); + return CodexPopulation.answer(snapshot, "codex_percent_research:alchemy"); + } + + private static void write(Path path, String... lines) throws IOException { + Files.createDirectories(path.getParent()); + Files.writeString(path, String.join("\n", lines) + "\n"); + } +} diff --git a/src/test/java/net/tfminecraft/rpcharacters/placeholder/RpCharactersExpansionTest.java b/src/test/java/net/tfminecraft/rpcharacters/placeholder/RpCharactersExpansionTest.java index f13accd7..058a361a 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/placeholder/RpCharactersExpansionTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/placeholder/RpCharactersExpansionTest.java @@ -60,4 +60,20 @@ class RpCharactersExpansionTest { assertEquals("§fAria", expansion.onRequest(player, "DISPLAY")); assertEquals("Unset", expansion.onRequest(null, "BIRTHDAY")); } + @Test void codexPopulationPlaceholdersAreGlobalAndBlankUntilASnapshotExists() { + assertNull(expansion.onRequest(player, "codex_percent")); + assertEquals("", expansion.onRequest(null, "codex_holders")); + assertEquals("", expansion.onRequest(player, "codex_percent_research:alchemy")); + assertEquals("", expansion.onRequest(null, "codex_holders_research:alchemy")); + assertEquals("", expansion.onRequest(player, "codex_percent_research")); + var snapshot = new CodexPopulation.Snapshot(Set.of("research:alchemy"), Map.of("research:alchemy", 1), 2); + CodexPopulationService.replaceSnapshot(snapshot); + try { + assertEquals("50", expansion.onRequest(player, "codex_percent_research:alchemy")); + assertEquals("1", expansion.onRequest(null, "CODEX_HOLDERS_RESEARCH:ALCHEMY")); + assertEquals("", expansion.onRequest(player, "codex_percent_research:missing")); + } finally { + CodexPopulationService.stop(); + } + } }