diff --git a/README.md b/README.md index 1694334..5ab25b4 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,10 @@ 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. -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 +Each roleplay character keeps its own journal. Exploration belongs to the account: +a character gets the discoveries its account's other characters made, with their +Player EXP, when it starts playing. Research stays with whoever found it, so a new +character researches afresh and earns those 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 diff --git a/src/main/java/net/tfminecraft/codex/Codex.java b/src/main/java/net/tfminecraft/codex/Codex.java index 3e10945..6f72169 100644 --- a/src/main/java/net/tfminecraft/codex/Codex.java +++ b/src/main/java/net/tfminecraft/codex/Codex.java @@ -64,7 +64,7 @@ public void onEnable(){ } characterJournals = new CharacterJournals(playerDataManager, new CharacterStore(getDataFolder().toPath()), - new LegacyRecipient(getDataFolder().getParentFile().toPath()), getLogger()); + new LegacyRecipient(getDataFolder().getParentFile().toPath()), new ExplorationManager(this), getLogger()); registerEvents(); registerCommands(); characterJournals.start(); diff --git a/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java b/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java index fba6043..d3245da 100644 --- a/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java +++ b/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java @@ -20,6 +20,7 @@ import net.tfminecraft.codex.managers.PlayerDataManager; import net.tfminecraft.codex.model.data.PlayerData; +import net.tfminecraft.codex.model.data.PlayerDataCategory; import net.tfminecraft.tlibs.character.CharacterInfo; import net.tfminecraft.tlibs.character.CharacterRef; import net.tfminecraft.tlibs.character.CharacterSessionEndEvent; @@ -30,21 +31,32 @@ * 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. + * entries stay readable out of character. Shared discoveries (see {@link SharedExploration}) are copied + * into the journal shortly after its session starts. Main thread only. */ public final class CharacterJournals implements Listener { + /** Shared discoveries arrive after the character's class and body are in place, and once the screen settles. */ + static final long SHARE_DELAY_TICKS = 40L; + private final PlayerDataManager players; private final CharacterStore store; private final Logger logger; private final LegacyRecipient recipients; + private final Exploration exploration; 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, LegacyRecipient recipients, Logger logger) { + /** An account's journal other than the played character's; {@code name} is null for the one from before characters. */ + private record Journal(List categories, String name, boolean alive) { + } + + public CharacterJournals(PlayerDataManager players, CharacterStore store, LegacyRecipient recipients, + Exploration exploration, Logger logger) { this.players = players; this.store = store; this.recipients = recipients; + this.exploration = exploration; this.logger = logger; } @@ -81,6 +93,9 @@ void open(Player player, CharacterRef ref) { 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())); + if (exploration.settings().enabled()) { + exploration.later(SHARE_DELAY_TICKS, () -> share(player, ref)); + } } 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); @@ -121,6 +136,31 @@ void seed(UUID owner) { } } + /** + * Copies into the played character's journal the shared discoveries its account found elsewhere, + * then runs their rewards that carry over. Only while that character is still in play. The journal + * is saved before the rewards run, so a crash can lose a reward but never pay it twice; a failed save + * keeps the journal in memory, saved with the next save. + */ + void share(Player player, CharacterRef ref) { + PlayerData journal = players.getPlayerByUUID(ref.ownerId()); + if (!player.isOnline() || journal == null || !ref.characterId().equals(journal.getCharacterId())) { + return; + } + List> others = new ArrayList<>(); + for (Journal other : otherJournals(ref)) { + others.add(other.categories()); + } + List copies = SharedExploration.copy(journal, others, exploration.settings()); + if (copies.isEmpty()) { + return; + } + save(journal); + logger.info("Shared " + copies.size() + " Codex discoveries of account " + ref.ownerId() + + " with its character " + ref.characterId()); + exploration.reward(player, copies); + } + /** 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()); @@ -186,31 +226,46 @@ private AccountKnowledge knowledgeOf(CharacterRef ref, String 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. + // What the account found before journals were per character is readable, but no character knows it. + for (Journal other : otherJournals(ref)) { + builder.add(other.categories(), other.name(), other.alive()); + } + for (CharacterInfo info : Characters.characters(ref.ownerId()).orElse(List.of())) { + if (info.ref().characterId().equals(ref.characterId())) { + name = info.name(); + } + } + return builder.build(name); + } + + /** The account's journal from before characters had their own, then each other character's, alive or dead. */ + private List otherJournals(CharacterRef ref) { + List journals = new ArrayList<>(); if (store.hasLegacy(ref.ownerId())) { try { - builder.add(store.readLegacy(ref.ownerId()).categories(), null, false); + journals.add(new Journal(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) { + for (CharacterInfo info : Characters.characters(ref.ownerId()).orElse(List.of())) { UUID id = info.ref().characterId(); if (id.equals(ref.characterId())) { - name = info.name(); - } else if (unsaved.containsKey(id)) { - builder.add(unsaved.get(id).getCategories(), info.name(), info.alive()); + continue; + } + if (unsaved.containsKey(id)) { + journals.add(new Journal(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()); + journals.add(new Journal(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); + return journals; } } diff --git a/src/main/java/net/tfminecraft/codex/character/Exploration.java b/src/main/java/net/tfminecraft/codex/character/Exploration.java new file mode 100644 index 0000000..d2c3728 --- /dev/null +++ b/src/main/java/net/tfminecraft/codex/character/Exploration.java @@ -0,0 +1,19 @@ +package net.tfminecraft.codex.character; + +import java.util.List; + +import org.bukkit.entity.Player; + +/** What sharing discoveries needs from the rest of Codex: its settings, the rewards and the scheduler. */ +public interface Exploration { + SharedExploration.Settings settings(); + + /** + * Runs the copies' reward lines that carry over and those of any category they complete, then + * tells the player what arrived. The copies are already in the player's journal. + */ + void reward(Player player, List copies); + + /** Runs the task on the main thread after the given number of ticks. */ + void later(long ticks, Runnable task); +} diff --git a/src/main/java/net/tfminecraft/codex/character/SharedExploration.java b/src/main/java/net/tfminecraft/codex/character/SharedExploration.java new file mode 100644 index 0000000..12976a5 --- /dev/null +++ b/src/main/java/net/tfminecraft/codex/character/SharedExploration.java @@ -0,0 +1,81 @@ +package net.tfminecraft.codex.character; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import net.tfminecraft.codex.model.data.PlayerData; +import net.tfminecraft.codex.model.data.PlayerDataCategory; +import net.tfminecraft.codex.model.data.PlayerDataDiscovery; + +/** + * Exploration belongs to the account: a character gets the discoveries its account's other characters + * and its journal from before characters had their own hold, with the reward lines that carry over + * (Player EXP), once. Entries kept per character, such as research, stay with whoever found them; the + * others keep seeing them out of character and earn them, rewards included, by finding them themselves. + */ +public final class SharedExploration { + /** A reward line starting with {@code from} runs for a copied discovery as {@code to} plus the rest of the line. */ + public record Rewrite(String from, String to) { + } + + /** A discovery copied into a journal. */ + public record Copy(String category, String discovery) { + } + + /** + * {@code keptPerCharacter} lists categories and {@code category:discovery} entries that are not shared. + * Reward lines without a matching rewrite are skipped when a discovery is copied. + */ + public record Settings(boolean enabled, Set keptPerCharacter, List rewards) { + public static final Settings OFF = new Settings(false, Set.of(), List.of()); + + public Settings { + keptPerCharacter = Set.copyOf(keptPerCharacter); + rewards = List.copyOf(rewards); + } + + public boolean shared(String category, String discovery) { + return enabled && !keptPerCharacter.contains(category) + && !keptPerCharacter.contains(AccountKnowledge.key(category, discovery)); + } + + /** The line to run for a copied discovery's reward line, or null when it does not carry over. */ + public String reward(String line) { + String trimmed = line.trim(); + for (Rewrite rewrite : rewards) { + if (trimmed.startsWith(rewrite.from())) { + return rewrite.to() + trimmed.substring(rewrite.from().length()); + } + } + return null; + } + } + + private SharedExploration() { + } + + /** + * Adds to the journal the shared discoveries it lacks from the other journals, keeping the date + * each was first found. Marks the journal modified when anything was added. + * @return the copies, in the order they were added + */ + static List copy(PlayerData journal, List> others, Settings settings) { + List copies = new ArrayList<>(); + for (List other : others) { + for (PlayerDataCategory category : other) { + for (PlayerDataDiscovery discovery : category.getDiscoveries()) { + String id = discovery.getDiscoveryName(); + if (settings.shared(category.getName(), id) && !journal.hasDiscovery(category.getName(), id)) { + journal.addDiscovery(category.getName(), id, discovery.getDiscoverDate()); + copies.add(new Copy(category.getName(), id)); + } + } + } + } + if (!copies.isEmpty()) { + journal.setModified(true); + } + return copies; + } +} diff --git a/src/main/java/net/tfminecraft/codex/config/MainConfigManager.java b/src/main/java/net/tfminecraft/codex/config/MainConfigManager.java index b3d3c0f..81c1bab 100644 --- a/src/main/java/net/tfminecraft/codex/config/MainConfigManager.java +++ b/src/main/java/net/tfminecraft/codex/config/MainConfigManager.java @@ -1,14 +1,28 @@ package net.tfminecraft.codex.config; import net.tfminecraft.codex.Codex; +import net.tfminecraft.codex.character.SharedExploration; +import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.FileConfiguration; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; public class MainConfigManager { + /** Entries that stay with the character that found them: research, and achievements counting research. */ + public static final List KEPT_PER_CHARACTER = List.of("research", "achievements:5_research", + "achievements:10_research", "achievements:25_research", "achievements:all_research", + "achievements:full_codex"); + /** Player EXP of a shared discovery goes through RPCharacters, which keeps what a capped class cannot take yet. */ + public static final String EXP_FROM = "console_command: mmocore admin exp give %player% main "; + public static final String EXP_TO = "console_command: rpcharacter admin carryexp %player% "; private Codex plugin; private CommonConfig configFile; @@ -20,6 +34,7 @@ public class MainConfigManager { private int progressBarPlaceholderAmount; private boolean useMiniMessage; private int configVersion; + private SharedExploration.Settings sharedExploration = SharedExploration.Settings.OFF; public MainConfigManager(Codex plugin){ this.plugin = plugin; @@ -37,6 +52,21 @@ public void configure() { progressBarPlaceholderAmount = config.getInt("progress_bar_placeholder.amount"); useMiniMessage = config.getBoolean("use_minimessage"); configVersion = config.getInt("config_version"); + sharedExploration = sharedExploration(config.getConfigurationSection("shared_discoveries")); + } + + private static SharedExploration.Settings sharedExploration(ConfigurationSection section) { + if (section == null) { + return SharedExploration.Settings.OFF; + } + List rewards = new ArrayList<>(); + for (Map reward : section.getMapList("rewards")) { + if (reward.get("from") instanceof String from && !from.isBlank() && reward.get("to") instanceof String to) { + rewards.add(new SharedExploration.Rewrite(from, to)); + } + } + return new SharedExploration.Settings(section.getBoolean("enabled"), + new HashSet<>(section.getStringList("keep_per_character")), rewards); } public boolean reloadConfig(){ @@ -55,6 +85,24 @@ public void checkUpdate(){ getConfig().set("use_minimessage",false); configFile.saveConfig(); } + if(!text.contains("shared_discoveries:")){ + FileConfiguration config = getConfig(); + config.set("shared_discoveries.enabled",true); + config.set("shared_discoveries.keep_per_character",KEPT_PER_CHARACTER); + Map exp = new LinkedHashMap<>(); + exp.put("from",EXP_FROM); + exp.put("to",EXP_TO); + config.set("shared_discoveries.rewards",List.of(exp)); + config.setComments("shared_discoveries",List.of( + "Exploration belongs to the account. When a character starts playing, it gets the", + "discoveries its account's other characters (alive or dead) and its journal from before", + "characters made, except the categories and category:discovery entries kept per character.", + "Those stay with whoever found them; the others see them out of character.", + "A copied discovery runs only its reward lines starting with a rewards 'from', as 'to'", + "plus the rest of the line (Player EXP goes through RPCharacters, which keeps what a", + "capped class can't take yet). Its other reward lines are skipped.")); + configFile.saveConfig(); + } }catch(IOException e){ e.printStackTrace(); } @@ -92,6 +140,10 @@ public int getProgressBarPlaceholderAmount() { return progressBarPlaceholderAmount; } + public SharedExploration.Settings getSharedExploration() { + return sharedExploration; + } + public boolean isUseMiniMessage() { return useMiniMessage; } diff --git a/src/main/java/net/tfminecraft/codex/config/MessagesConfigManager.java b/src/main/java/net/tfminecraft/codex/config/MessagesConfigManager.java index ff9a16c..ef8d099 100644 --- a/src/main/java/net/tfminecraft/codex/config/MessagesConfigManager.java +++ b/src/main/java/net/tfminecraft/codex/config/MessagesConfigManager.java @@ -14,6 +14,10 @@ public class MessagesConfigManager { 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."; + public static final String OUT_OF_CHARACTER_ADVICE = + "&8&oUse common sense: play %character% as not knowing this until they learn it in character."; + public static final String SHARED_DISCOVERIES = + "&7%amount% Codex entries carried over from your other characters, with their Player EXP."; private Codex plugin; @@ -63,6 +67,14 @@ public void checkUpdates(){ config.set("knownOutOfCharacterOnly",OUT_OF_CHARACTER_ONLY); configFile.saveConfig(); } + if(!text.contains("knownOutOfCharacterOnlyAdvice:")){ + config.set("knownOutOfCharacterOnlyAdvice",OUT_OF_CHARACTER_ADVICE); + configFile.saveConfig(); + } + if(!text.contains("sharedDiscoveries:")){ + config.set("sharedDiscoveries",SHARED_DISCOVERIES); + configFile.saveConfig(); + } if(!text.contains("noCharacterInPlay:")){ config.set("noCharacterInPlay",NO_CHARACTER_IN_PLAY); configFile.saveConfig(); diff --git a/src/main/java/net/tfminecraft/codex/managers/ExplorationManager.java b/src/main/java/net/tfminecraft/codex/managers/ExplorationManager.java new file mode 100644 index 0000000..54fb738 --- /dev/null +++ b/src/main/java/net/tfminecraft/codex/managers/ExplorationManager.java @@ -0,0 +1,86 @@ +package net.tfminecraft.codex.managers; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import net.tfminecraft.codex.Codex; +import net.tfminecraft.codex.character.Exploration; +import net.tfminecraft.codex.character.SharedExploration; +import net.tfminecraft.codex.config.MessagesConfigManager; +import net.tfminecraft.codex.model.internal.CommonVariable; +import net.tfminecraft.codex.model.structure.Category; +import net.tfminecraft.codex.model.structure.Discovery; +import net.tfminecraft.codex.utils.ActionUtils; + +/** + * Codex's side of shared discoveries: the {@code shared_discoveries} settings, and the reward lines a + * copied discovery carries over. Messages, titles and sounds are left out; one summary message is sent instead. + */ +public class ExplorationManager implements Exploration { + private final Codex plugin; + + public ExplorationManager(Codex plugin) { + this.plugin = plugin; + } + + @Override + public SharedExploration.Settings settings() { + return plugin.getConfigsManager().getMainConfigManager().getSharedExploration(); + } + + @Override + public void reward(Player player, List copies) { + SharedExploration.Settings settings = settings(); + CategoryManager categories = plugin.getCategoryManager(); + PlayerDataManager players = plugin.getPlayerDataManager(); + Set touched = new LinkedHashSet<>(); + for (SharedExploration.Copy copy : copies) { + Category category = categories.getCategory(copy.category()); + Discovery discovery = category == null ? null : category.getDiscovery(copy.discovery()); + if (discovery == null) { + continue; + } + touched.add(category); + List rewards = discovery.getCustomRewards() != null + ? discovery.getCustomRewards() : category.getDefaultRewardsPerDiscovery(); + run(player, rewards, settings, discovery.getName()); + } + // As when a discovery is found: completing a category pays its completion rewards once. + for (Category category : touched) { + String name = category.getName(); + if (players.hasAllDiscoveries(player, name, category.getDiscoveries().size()) + && !players.hasCompletedCategory(player, name)) { + players.completeCategory(player, name); + run(player, category.getDefaultRewardsAllDiscoveries(), settings, name); + } + } + String message = plugin.getMessagesConfig().getString("sharedDiscoveries", MessagesConfigManager.SHARED_DISCOVERIES); + if (message != null && !message.isEmpty()) { + plugin.getMessagesManager().sendMessage(player, message.replace("%amount%", String.valueOf(copies.size())), true); + } + } + + private void run(Player player, List rewards, SharedExploration.Settings settings, String name) { + if (rewards == null) { + return; + } + ArrayList variables = new ArrayList<>(); + variables.add(new CommonVariable("%name%", name)); + for (String line : rewards) { + String carried = settings.reward(line); + if (carried != null) { + ActionUtils.executeAction(player, carried, plugin, variables); + } + } + } + + @Override + public void later(long ticks, Runnable task) { + Bukkit.getScheduler().runTaskLater(plugin, task, ticks); + } +} diff --git a/src/main/java/net/tfminecraft/codex/managers/InventoryManager.java b/src/main/java/net/tfminecraft/codex/managers/InventoryManager.java index 203c619..d3c5fb4 100644 --- a/src/main/java/net/tfminecraft/codex/managers/InventoryManager.java +++ b/src/main/java/net/tfminecraft/codex/managers/InventoryManager.java @@ -361,6 +361,10 @@ private List inCharacterLore(AccountKnowledge knowledge,PlayerData curre if(current == null || !current.hasDiscovery(categoryName,discoveryName)){ lines.add(messages.getString("knownOutOfCharacterOnly",MessagesConfigManager.OUT_OF_CHARACTER_ONLY) .replace("%character%",knowledge.characterName())); + String advice = messages.getString("knownOutOfCharacterOnlyAdvice",MessagesConfigManager.OUT_OF_CHARACTER_ADVICE); + if(!advice.isEmpty()){ + lines.add(advice.replace("%character%",knowledge.characterName())); + } } return lines; } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 9ae8754..c7635fc 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -11,6 +11,26 @@ progress_bar_placeholder: empty_symbol: "&c|" amount: 20 +# Exploration belongs to the account. When a character starts playing, it gets the +# discoveries its account's other characters (alive or dead) and its journal from before +# characters made, except the categories and category:discovery entries kept per character. +# Those stay with whoever found them; the others see them out of character. +# A copied discovery runs only its reward lines starting with a rewards 'from', as 'to' +# plus the rest of the line (Player EXP goes through RPCharacters, which keeps what a +# capped class can't take yet). Its other reward lines are skipped. +shared_discoveries: + enabled: true + keep_per_character: + - research + - achievements:5_research + - achievements:10_research + - achievements:25_research + - achievements:all_research + - achievements:full_codex + rewards: + - from: "console_command: mmocore admin exp give %player% main " + to: "console_command: rpcharacter admin carryexp %player% " + # Defines if every message/text in the plugin should use the MiniMessage format. # Only works on Paper 1.19.4+. use_minimessage: false diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index 01c7dab..da4b379 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -28,6 +28,8 @@ 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." +knownOutOfCharacterOnlyAdvice: "&8&oUse common sense: play %character% as not knowing this until they learn it in character." +sharedDiscoveries: "&7%amount% Codex entries carried over from your other characters, with their Player EXP." noCharacterInPlay: "&cChoose a character to open the Codex." seconds: s minutes: m diff --git a/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java b/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java index d23ce55..219d9eb 100644 --- a/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java +++ b/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java @@ -55,6 +55,42 @@ class CharacterJournalsTest { private CharacterStore store; private CharacterJournals journals; private Player player; + private FakeExploration exploration; + + /** Shares everything but research and the "secret" achievement, carrying the Player EXP lines. */ + private static final SharedExploration.Settings SHARING = new SharedExploration.Settings(true, + Set.of("research", "achievements:secret"), + List.of(new SharedExploration.Rewrite("console_command: mmocore admin exp give %player% main ", + "console_command: rpcharacter admin carryexp %player% "))); + + private static final class FakeExploration implements Exploration { + SharedExploration.Settings settings = SharedExploration.Settings.OFF; + final List delays = new ArrayList<>(); + final List tasks = new ArrayList<>(); + final List> rewarded = new ArrayList<>(); + + @Override + public SharedExploration.Settings settings() { + return settings; + } + + @Override + public void reward(Player player, List copies) { + rewarded.add(copies); + } + + @Override + public void later(long ticks, Runnable task) { + delays.add(ticks); + tasks.add(task); + } + + void runAll() { + List due = new ArrayList<>(tasks); + tasks.clear(); + due.forEach(Runnable::run); + } + } @BeforeEach void setUp() { @@ -77,7 +113,8 @@ public void flush() { public void close() { } }); - journals = new CharacterJournals(players, store, new LegacyRecipient(folder.resolve("plugins")), logger); + exploration = new FakeExploration(); + journals = new CharacterJournals(players, store, new LegacyRecipient(folder.resolve("plugins")), exploration, logger); player = mock(Player.class); when(player.getUniqueId()).thenReturn(owner); when(player.getName()).thenReturn("Steve"); @@ -360,6 +397,102 @@ void refreshingRereadsOtherCharacters() throws IOException { assertEquals("Aldric", journals.knowledge(player).characterName()); } + @Test + void sharingIsOffUnlessConfigured() throws IOException { + saved(second, "aurea"); + characters.when(() -> Characters.characters(owner)).thenReturn(Optional.of(List.of( + info(first, "Aldric", CharacterStatus.ALIVE), info(second, "Mira", CharacterStatus.ALIVE)))); + + journals.onStart(start(first)); + + assertTrue(exploration.tasks.isEmpty()); + assertFalse(players.getPlayer(player).hasDiscovery("poi", "aurea")); + } + + @Test + void aCharacterGetsItsAccountsExplorationButNotItsResearch() throws IOException { + exploration.settings = SHARING; + when(player.isOnline()).thenReturn(true); + UUID dead = UUID.randomUUID(); + PlayerData mira = new PlayerData(owner, second, "Steve"); + mira.addDiscovery("poi", "aurea", "01/10/2026"); + mira.addDiscovery("research", "ignitium", "02/10/2026"); + mira.addDiscovery("achievements", "secret", "03/10/2026"); + mira.addDiscovery("achievements", "die", "04/10/2026"); + store.save(mira); + saved(dead, "vard"); + legacy("syrindell"); + // Already copied to Mira once; the account file still counts as a source. + store.markSeeded(owner, second); + 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)))); + + journals.onStart(start(first)); + assertEquals(List.of(CharacterJournals.SHARE_DELAY_TICKS), exploration.delays); + assertFalse(players.getPlayer(player).hasDiscovery("poi", "aurea"), "Nothing arrives before the delay"); + exploration.runAll(); + + PlayerData aldric = players.getPlayer(player); + assertTrue(aldric.hasDiscovery("poi", "syrindell"), "The journal from before characters counts"); + assertTrue(aldric.hasDiscovery("poi", "aurea")); + assertTrue(aldric.hasDiscovery("poi", "vard"), "Dead characters count"); + assertTrue(aldric.hasDiscovery("achievements", "die")); + assertFalse(aldric.hasDiscovery("research", "ignitium")); + assertFalse(aldric.hasDiscovery("achievements", "secret")); + assertEquals("01/10/2026", aldric.getDiscovery("poi", "aurea").getDiscoverDate(), "The first find's date is kept"); + assertEquals(List.of(new SharedExploration.Copy("poi", "syrindell"), new SharedExploration.Copy("poi", "aurea"), + new SharedExploration.Copy("achievements", "die"), new SharedExploration.Copy("poi", "vard")), + exploration.rewarded.getFirst()); + assertTrue(store.load(owner, first, "Steve").hasDiscovery("poi", "aurea"), "The journal is saved before rewards run"); + assertLogged(Level.INFO, "Shared 4 Codex discoveries of account " + owner + " with its character " + first); + + journals.onStart(start(first)); + exploration.runAll(); + assertEquals(1, exploration.rewarded.size(), "Nothing is shared or paid twice"); + } + + @Test + void sharingWaitsForTheSameCharacterToStillBeInPlay() throws IOException { + exploration.settings = SHARING; + saved(second, "aurea"); + characters.when(() -> Characters.characters(owner)).thenReturn(Optional.of(List.of( + info(first, "Aldric", CharacterStatus.ALIVE), info(second, "Mira", CharacterStatus.ALIVE)))); + + journals.onStart(start(first)); + exploration.runAll(); + assertFalse(players.getPlayer(player).hasDiscovery("poi", "aurea"), "An offline player gets nothing"); + + when(player.isOnline()).thenReturn(true); + journals.onStart(start(first)); + journals.onStart(start(second)); + exploration.runAll(); + assertFalse(store.load(owner, first, "Steve").hasDiscovery("poi", "aurea"), "A switched-away character gets nothing"); + + journals.onStart(start(first)); + journals.onQuit(new PlayerQuitEvent(player, "bye")); + exploration.runAll(); + assertTrue(exploration.rewarded.isEmpty()); + } + + @Test + void sharedDiscoveriesStillArriveWhenTheJournalCannotBeSaved() throws IOException { + exploration.settings = SHARING; + when(player.isOnline()).thenReturn(true); + saved(second, "aurea"); + characters.when(() -> Characters.characters(owner)).thenReturn(Optional.of(List.of( + info(first, "Aldric", CharacterStatus.ALIVE), info(second, "Mira", CharacterStatus.ALIVE)))); + journals.onStart(start(first)); + Files.createDirectories(store.characterFile(first)); + + exploration.runAll(); + + assertTrue(players.getPlayer(player).hasDiscovery("poi", "aurea")); + assertTrue(players.getPlayer(player).isModified(), "The journal waits for the next save"); + assertEquals(1, exploration.rewarded.size()); + assertLogged(Level.SEVERE, "Could not save Codex for character " + first); + } + private CharacterInfo played(UUID character, String name, long seconds) { return new CharacterInfo(new CharacterRef(owner, character), name, CharacterStatus.ALIVE, null, Set.of(), seconds, null); } diff --git a/src/test/java/net/tfminecraft/codex/character/SharedExplorationTest.java b/src/test/java/net/tfminecraft/codex/character/SharedExplorationTest.java new file mode 100644 index 0000000..4a304f4 --- /dev/null +++ b/src/test/java/net/tfminecraft/codex/character/SharedExplorationTest.java @@ -0,0 +1,77 @@ +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.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +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 SharedExplorationTest { + private final SharedExploration.Settings settings = new SharedExploration.Settings(true, + new HashSet<>(Set.of("research", "achievements:5_research")), + new ArrayList<>(List.of(new SharedExploration.Rewrite("console_command: mmocore admin exp give %player% main ", + "console_command: rpcharacter admin carryexp %player% "), + new SharedExploration.Rewrite("message: ", "message: ")))); + + @Test + void entriesKeptPerCharacterAreNeverShared() { + assertTrue(settings.shared("poi", "aurea")); + assertTrue(settings.shared("achievements", "die")); + assertFalse(settings.shared("research", "ignitium")); + assertFalse(settings.shared("achievements", "5_research")); + assertFalse(SharedExploration.Settings.OFF.shared("poi", "aurea")); + assertThrows(UnsupportedOperationException.class, () -> settings.keptPerCharacter().add("poi")); + assertThrows(UnsupportedOperationException.class, () -> settings.rewards().clear()); + } + + @Test + void onlyRewardLinesWithARewriteCarryOver() { + assertEquals("console_command: rpcharacter admin carryexp %player% 50", + settings.reward(" console_command: mmocore admin exp give %player% main 50")); + assertEquals("message: hi", settings.reward("message: hi")); + assertNull(settings.reward("title: 20;60;20;&a&lCODEX UPDATED;&7%name%")); + assertNull(settings.reward("console_command: mmocore admin exp give %player% profession 50")); + } + + @Test + void copiesAddOnlyMissingSharedEntriesWithTheirDates() { + UUID owner = UUID.randomUUID(); + PlayerData journal = new PlayerData(owner, UUID.randomUUID(), "Steve"); + journal.addDiscovery("poi", "aurea", "05/10/2026"); + List mira = List.of(category("poi", "aurea", "vard"), category("research", "ignitium")); + List tom = List.of(category("poi", "vard", "syrindell"), category("achievements", "5_research", "die")); + + List copies = SharedExploration.copy(journal, List.of(mira, tom), settings); + + assertEquals(List.of(new SharedExploration.Copy("poi", "vard"), new SharedExploration.Copy("poi", "syrindell"), + new SharedExploration.Copy("achievements", "die")), copies); + assertEquals("05/10/2026", journal.getDiscovery("poi", "aurea").getDiscoverDate(), "Own entries keep their date"); + assertEquals("01/10/2026", journal.getDiscovery("poi", "vard").getDiscoverDate()); + assertFalse(journal.hasDiscovery("research", "ignitium")); + assertTrue(journal.isModified()); + + journal.setModified(false); + assertEquals(List.of(), SharedExploration.copy(journal, List.of(mira, tom), settings)); + assertFalse(journal.isModified(), "Nothing new leaves the journal unchanged"); + } + + private static PlayerDataCategory category(String name, String... discoveries) { + ArrayList found = new ArrayList<>(); + for (String discovery : discoveries) { + found.add(new PlayerDataDiscovery(discovery, "01/10/2026", 0)); + } + return new PlayerDataCategory(name, false, found); + } +}