diff --git a/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexLiveReader.java b/src/main/java/net/tfminecraft/rpcharacters/placeholder/CodexLiveReader.java index dd0ca0f3..0a8c29a3 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() {} @@ -32,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) { @@ -50,7 +75,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 +84,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..25ec5e26 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; @@ -91,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; } @@ -112,7 +122,10 @@ 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 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) { return; 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 512df921..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; @@ -72,6 +74,41 @@ 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 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); @@ -161,7 +198,7 @@ public Object getPlayers() { } } - static final class MemoryPlayer { + static class MemoryPlayer { Object uuid; Object categories; @@ -174,6 +211,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..7339daae 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulationTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/placeholder/CodexPopulationTest.java @@ -98,6 +98,46 @@ 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()); + 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")); + } + @Test void onlinePlayersReplaceTheirSavedFiles() { UUID saved = UUID.randomUUID(); UUID online = UUID.randomUUID(); 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; + } +}