From aca3d5f33d41f64f49bdf09b5b25457517a27ea5 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:23:20 +0000 Subject: [PATCH 1/2] Count Codex discoveries per character TFMC Codex keeps a journal per character in characters/. Read those files when the folder exists and key live journals by character ID, so the codex_percent_ and codex_holders_ placeholders count characters. Upstream Codex's per-account players/ files still work. Co-Authored-By: Claude Opus 5.5 --- .../placeholder/CodexLiveReader.java | 15 ++++++-- .../placeholder/CodexPopulation.java | 13 ++++--- .../placeholder/CodexLiveReaderTest.java | 26 +++++++++++++- .../placeholder/CodexPopulationTest.java | 35 +++++++++++++++++++ 4 files changed, 81 insertions(+), 8 deletions(-) diff --git a/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexLiveReader.java b/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexLiveReader.java index dd0ca0f3..e1cd94a1 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexLiveReader.java +++ b/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexLiveReader.java @@ -14,8 +14,9 @@ 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. + * Open Codex journals, read from Codex's own player map and keyed like the saved files: by + * character for TFMC Codex, by account for upstream Codex. Codex drops a journal when its + * session ends, so this is only the live overlay on top of the saved files. */ final class CodexLiveReader { private CodexLiveReader() {} @@ -50,7 +51,7 @@ static Map>> read(Object plugin) throws Reflective if (value == null) { continue; } - Object uuidRaw = value.getClass().getMethod("getUuid").invoke(value); + Object uuidRaw = journalId(value); if (!(uuidRaw instanceof UUID uuid)) { continue; } @@ -59,6 +60,14 @@ static Map>> read(Object plugin) throws Reflective return result; } + private static Object journalId(Object player) throws ReflectiveOperationException { + try { + return player.getClass().getMethod("getCharacterId").invoke(player); + } catch (NoSuchMethodException upstream) { + return player.getClass().getMethod("getUuid").invoke(player); + } + } + private static Map> categoriesOf(Object player) throws ReflectiveOperationException { Object raw = player.getClass().getMethod("getCategories").invoke(player); if (!(raw instanceof Iterable categories)) { diff --git a/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulation.java b/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulation.java index 6af48157..252258d4 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulation.java +++ b/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulation.java @@ -16,9 +16,10 @@ 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}. + * Counts how many Codex journals have each discovery. Category ids are the file names in + * {@code plugins/Codex/categories}, and each journal lists discoveries as {@code id;date;millis}. + * TFMC Codex keeps one journal per character in {@code characters/}; upstream Codex keeps one per + * account in {@code players/}. */ final class CodexPopulation { static final int MAX_FILE_BYTES = 1024 * 1024; @@ -112,7 +113,11 @@ static Census read(Path codexFolder, LineReader reader, DirectoryLister lister) defined.addAll(categoryDiscoveries(name, stripBom(lines))); }); Map>> players = new HashMap<>(); - eachYaml(codexFolder.resolve("players"), lister, skipped, path -> { + Path journals = codexFolder.resolve("characters"); + if (!Files.isDirectory(journals)) { + journals = codexFolder.resolve("players"); + } + eachYaml(journals, lister, skipped, path -> { UUID uuid = playerId(path); if (uuid == null) { return; diff --git a/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexLiveReaderTest.java b/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexLiveReaderTest.java index 512df921..e677d6f2 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexLiveReaderTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexLiveReaderTest.java @@ -72,6 +72,22 @@ class CodexLiveReaderTest { assertThrows(ReflectiveOperationException.class, () -> CodexLiveReader.read(new Object())); } + @Test void tfmcCodexJournalsAreKeyedByCharacter() throws Exception { + UUID account = UUID.randomUUID(); + UUID character = UUID.randomUUID(); + MemoryCodex codex = new MemoryCodex(); + MemoryCodex.MemoryJournal journal = new MemoryCodex.MemoryJournal(); + journal.uuid = account; + journal.characterId = character; + journal.categories = List.of(category("poi", List.of(discovery("Aurea")))); + codex.manager.players.put(account, journal); + + Map>> live = CodexLiveReader.read(codex); + + assertEquals(Set.of(character), live.keySet()); + assertEquals(set("aurea"), live.get(character).get("poi")); + } + @Test void utilityConstructorCanBeInvoked() throws Exception { var ctor = CodexLiveReader.class.getDeclaredConstructor(); ctor.setAccessible(true); @@ -161,7 +177,7 @@ public Object getPlayers() { } } - static final class MemoryPlayer { + static class MemoryPlayer { Object uuid; Object categories; @@ -174,6 +190,14 @@ public Object getCategories() { } } + static class MemoryJournal extends MemoryPlayer { + Object characterId; + + public Object getCharacterId() { + return characterId; + } + } + static final class MemoryCategory { Object name; Object discoveries; diff --git a/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulationTest.java b/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulationTest.java index b7c96bfc..91e6900a 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulationTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulationTest.java @@ -98,6 +98,41 @@ class CodexPopulationTest { assertEquals(2, snapshot.players()); } + @Test void tfmcCodexCountsCharacterJournalsInsteadOfAccounts() throws Exception { + Path codex = temp.resolve("Codex"); + Files.createDirectories(codex.resolve("categories")); + write(codex.resolve("categories/poi.yml"), + "discoveries:", + " aurea:", + " name: Aurea"); + UUID account = UUID.randomUUID(); + write(codex.resolve("players/" + account + ".yml"), + "categories:", + " poi:", + " discoveries:", + " - aurea;01/10/2026;0"); + UUID first = UUID.randomUUID(); + UUID second = UUID.randomUUID(); + write(codex.resolve("characters/" + first + ".yml"), + "owner: " + account, + "name: Steve", + "categories:", + " poi:", + " completed: false", + " discoveries:", + " - aurea;01/10/2026;0"); + write(codex.resolve("characters/" + second + ".yml"), + "owner: " + account, + "name: Steve"); + + CodexPopulation.Census census = CodexPopulation.read(codex); + + assertEquals(Set.of(first, second), census.players().keySet()); + CodexPopulation.Snapshot snapshot = CodexPopulation.combine(census, Map.of()); + assertEquals("1", CodexPopulation.answer(snapshot, "codex_holders_poi:aurea")); + assertEquals("50", CodexPopulation.answer(snapshot, "codex_percent_poi:aurea")); + } + @Test void onlinePlayersReplaceTheirSavedFiles() { UUID saved = UUID.randomUUID(); UUID online = UUID.randomUUID(); From 82a109fdcd48b00ddcf2901a5303c66250c2ad3d Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:50:43 +0000 Subject: [PATCH 2/2] Ask the running Codex which layout it uses A characters/ folder left behind after a rollback to upstream Codex would make the census read stale character files while live data is keyed by account. Check the running Codex's PlayerData for getCharacterId instead, and fall back to the folders only when Codex is not running. Co-Authored-By: Claude Opus 5.5 --- .../placeholder/CodexLiveReader.java | 24 +++++++++++++++++++ .../placeholder/CodexPopulation.java | 18 ++++++++++---- .../placeholder/CodexPopulationService.java | 2 +- .../placeholder/CodexLiveReaderTest.java | 21 ++++++++++++++++ .../placeholder/CodexPopulationTest.java | 5 ++++ .../codexfixture/CodexFixture.java | 5 ++++ .../codexfixture/model/data/PlayerData.java | 9 +++++++ .../codexupstream/CodexUpstream.java | 5 ++++ .../codexupstream/model/data/PlayerData.java | 9 +++++++ 9 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 src/test/java/net/tfminecraft/rpcharacters/placeholder/codexfixture/CodexFixture.java create mode 100644 src/test/java/net/tfminecraft/rpcharacters/placeholder/codexfixture/model/data/PlayerData.java create mode 100644 src/test/java/net/tfminecraft/rpcharacters/placeholder/codexupstream/CodexUpstream.java create mode 100644 src/test/java/net/tfminecraft/rpcharacters/placeholder/codexupstream/model/data/PlayerData.java diff --git a/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexLiveReader.java b/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexLiveReader.java index e1cd94a1..0a8c29a3 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexLiveReader.java +++ b/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexLiveReader.java @@ -33,6 +33,30 @@ static Map>> current() throws ReflectiveOperationE return read(plugin); } + /** + * Whether the running Codex keeps a journal per character, judged by its own {@code PlayerData} + * class, or null when Codex is not running. A folder left behind by another Codex build is no guide. + */ + static Boolean perCharacter() { + PluginManager plugins = Bukkit.getPluginManager(); + Plugin plugin = plugins == null ? null : plugins.getPlugin("Codex"); + if (plugin == null || !plugin.isEnabled()) { + return null; + } + return perCharacter(plugin); + } + + static boolean perCharacter(Object plugin) { + Class main = plugin.getClass(); + try { + Class data = Class.forName(main.getPackageName() + ".model.data.PlayerData", false, main.getClassLoader()); + data.getMethod("getCharacterId"); + return true; + } catch (ClassNotFoundException | NoSuchMethodException upstream) { + return false; + } + } + static Map>> read(Object plugin) throws ReflectiveOperationException { Object manager = plugin.getClass().getMethod("getPlayerDataManager").invoke(plugin); if (manager == null) { diff --git a/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulation.java b/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulation.java index 252258d4..25ec5e26 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulation.java +++ b/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulation.java @@ -92,10 +92,19 @@ static String answer(Snapshot snapshot, String params) { } static Census read(Path codexFolder) { - return read(codexFolder, CodexPopulation::defaultLines, CodexPopulation::defaultList); + return read(codexFolder, null); + } + + static Census read(Path codexFolder, Boolean perCharacter) { + return read(codexFolder, perCharacter, CodexPopulation::defaultLines, CodexPopulation::defaultList); } static Census read(Path codexFolder, LineReader reader, DirectoryLister lister) { + return read(codexFolder, null, reader, lister); + } + + /** {@code perCharacter} comes from the running Codex; when it is null, the folders decide. */ + static Census read(Path codexFolder, Boolean perCharacter, LineReader reader, DirectoryLister lister) { if (codexFolder == null || !Files.isDirectory(codexFolder)) { return Census.EMPTY; } @@ -113,10 +122,9 @@ static Census read(Path codexFolder, LineReader reader, DirectoryLister lister) defined.addAll(categoryDiscoveries(name, stripBom(lines))); }); Map>> players = new HashMap<>(); - Path journals = codexFolder.resolve("characters"); - if (!Files.isDirectory(journals)) { - journals = codexFolder.resolve("players"); - } + Path characters = codexFolder.resolve("characters"); + boolean byCharacter = perCharacter != null ? perCharacter : Files.isDirectory(characters); + Path journals = byCharacter ? characters : codexFolder.resolve("players"); eachYaml(journals, lister, skipped, path -> { UUID uuid = playerId(path); if (uuid == null) { diff --git a/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulationService.java b/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulationService.java index 8b8f07cc..2e8e293a 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulationService.java +++ b/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulationService.java @@ -37,7 +37,7 @@ public final class CodexPopulationService { private int reportedUnreadable = -1; private boolean liveFailureLogged; - Scanner scanner = CodexPopulation::read; + Scanner scanner = folder -> CodexPopulation.read(folder, CodexLiveReader.perCharacter()); LiveReader liveReader = CodexLiveReader::current; @FunctionalInterface diff --git a/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexLiveReaderTest.java b/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexLiveReaderTest.java index e677d6f2..899bada9 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexLiveReaderTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexLiveReaderTest.java @@ -1,6 +1,8 @@ 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.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -88,6 +90,25 @@ class CodexLiveReaderTest { assertEquals(set("aurea"), live.get(character).get("poi")); } + @Test void tellsWhichCodexIsRunning() { + assertTrue(CodexLiveReader.perCharacter(new net.tfminecraft.rpcharacters.placeholder.codexfixture.CodexFixture())); + assertFalse(CodexLiveReader.perCharacter(new net.tfminecraft.rpcharacters.placeholder.codexupstream.CodexUpstream())); + assertFalse(CodexLiveReader.perCharacter(new Object())); + + PluginManager manager = mock(PluginManager.class); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(null); + assertNull(CodexLiveReader.perCharacter()); + bukkit.when(Bukkit::getPluginManager).thenReturn(manager); + assertNull(CodexLiveReader.perCharacter()); + Plugin codex = mock(Plugin.class); + when(manager.getPlugin("Codex")).thenReturn(codex); + assertNull(CodexLiveReader.perCharacter()); + when(codex.isEnabled()).thenReturn(true); + assertFalse(CodexLiveReader.perCharacter()); + } + } + @Test void utilityConstructorCanBeInvoked() throws Exception { var ctor = CodexLiveReader.class.getDeclaredConstructor(); ctor.setAccessible(true); diff --git a/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulationTest.java b/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulationTest.java index 91e6900a..7339daae 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulationTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulationTest.java @@ -128,6 +128,11 @@ class CodexPopulationTest { CodexPopulation.Census census = CodexPopulation.read(codex); assertEquals(Set.of(first, second), census.players().keySet()); + assertEquals(Set.of(first, second), CodexPopulation.read(codex, true, + CodexPopulation::defaultLines, CodexPopulation::defaultList).players().keySet()); + // After a rollback to upstream Codex the old characters/ folder is ignored. + assertEquals(Set.of(account), CodexPopulation.read(codex, false, + CodexPopulation::defaultLines, CodexPopulation::defaultList).players().keySet()); CodexPopulation.Snapshot snapshot = CodexPopulation.combine(census, Map.of()); assertEquals("1", CodexPopulation.answer(snapshot, "codex_holders_poi:aurea")); assertEquals("50", CodexPopulation.answer(snapshot, "codex_percent_poi:aurea")); diff --git a/src/test/java/net/tfminecraft/rpcharacters/placeholder/codexfixture/CodexFixture.java b/src/test/java/net/tfminecraft/rpcharacters/placeholder/codexfixture/CodexFixture.java new file mode 100644 index 00000000..3a85de05 --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/placeholder/codexfixture/CodexFixture.java @@ -0,0 +1,5 @@ +package net.tfminecraft.rpcharacters.placeholder.codexfixture; + +/** Stands in for TFMC Codex's main class; its PlayerData has getCharacterId. */ +public final class CodexFixture { +} diff --git a/src/test/java/net/tfminecraft/rpcharacters/placeholder/codexfixture/model/data/PlayerData.java b/src/test/java/net/tfminecraft/rpcharacters/placeholder/codexfixture/model/data/PlayerData.java new file mode 100644 index 00000000..b0f0580a --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/placeholder/codexfixture/model/data/PlayerData.java @@ -0,0 +1,9 @@ +package net.tfminecraft.rpcharacters.placeholder.codexfixture.model.data; + +import java.util.UUID; + +public final class PlayerData { + public UUID getCharacterId() { + return null; + } +} diff --git a/src/test/java/net/tfminecraft/rpcharacters/placeholder/codexupstream/CodexUpstream.java b/src/test/java/net/tfminecraft/rpcharacters/placeholder/codexupstream/CodexUpstream.java new file mode 100644 index 00000000..56f2f237 --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/placeholder/codexupstream/CodexUpstream.java @@ -0,0 +1,5 @@ +package net.tfminecraft.rpcharacters.placeholder.codexupstream; + +/** Stands in for upstream Codex's main class; its PlayerData is keyed by account. */ +public final class CodexUpstream { +} diff --git a/src/test/java/net/tfminecraft/rpcharacters/placeholder/codexupstream/model/data/PlayerData.java b/src/test/java/net/tfminecraft/rpcharacters/placeholder/codexupstream/model/data/PlayerData.java new file mode 100644 index 00000000..3f147fb2 --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/placeholder/codexupstream/model/data/PlayerData.java @@ -0,0 +1,9 @@ +package net.tfminecraft.rpcharacters.placeholder.codexupstream.model.data; + +import java.util.UUID; + +public final class PlayerData { + public UUID getUuid() { + return null; + } +}