diff --git a/src/main/java/net/tfminecraft/conditionalevents/ConditionalEvents.java b/src/main/java/net/tfminecraft/conditionalevents/ConditionalEvents.java index 7a466b0..65522ee 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/ConditionalEvents.java +++ b/src/main/java/net/tfminecraft/conditionalevents/ConditionalEvents.java @@ -1,6 +1,8 @@ package net.tfminecraft.conditionalevents; import net.tfminecraft.conditionalevents.character.CharacterTracker; +import net.tfminecraft.conditionalevents.character.LegacyRecipient; +import net.tfminecraft.conditionalevents.model.CEEvent; import net.tfminecraft.conditionalevents.api.ConditionalEventsAPI; import net.tfminecraft.conditionalevents.api.ExpansionCE; import net.tfminecraft.conditionalevents.configs.ConfigsManager; @@ -63,7 +65,10 @@ public void onEnable(){ this.interruptEventManager = new InterruptEventManager(this); this.configsManager = new ConfigsManager(this); this.configsManager.configure(); - this.characterTracker = new CharacterTracker(); + this.characterTracker = new CharacterTracker(playerManager, name -> { + CEEvent event = eventsManager.getEvent(name); + return event != null && event.usesCharacterData(); + }, new LegacyRecipient(getDataFolder().getParentFile().toPath()), getLogger()); registerEvents(); registerCommands(); diff --git a/src/main/java/net/tfminecraft/conditionalevents/character/CharacterTracker.java b/src/main/java/net/tfminecraft/conditionalevents/character/CharacterTracker.java index af79f7d..40e9e66 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/character/CharacterTracker.java +++ b/src/main/java/net/tfminecraft/conditionalevents/character/CharacterTracker.java @@ -1,9 +1,13 @@ package net.tfminecraft.conditionalevents.character; +import java.io.IOException; import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Predicate; +import java.util.logging.Level; +import java.util.logging.Logger; import org.bukkit.Bukkit; import org.bukkit.entity.Player; @@ -12,6 +16,8 @@ import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerQuitEvent; +import net.tfminecraft.conditionalevents.managers.PlayerManager; +import net.tfminecraft.conditionalevents.model.player.PlayerData; import net.tfminecraft.tlibs.character.CharacterRef; import net.tfminecraft.tlibs.character.CharacterSessionEndEvent; import net.tfminecraft.tlibs.character.CharacterSessionStartEvent; @@ -19,10 +25,23 @@ /** * Which character each player is playing, readable from any thread, since events such as chat run - * off the main thread. + * off the main thread. On an account's first session it also gives the account's old data for + * per-character events to its most-played character, matching what Codex does with old journals. */ public final class CharacterTracker implements Listener { private final Map current = new ConcurrentHashMap<>(); + private final PlayerManager players; + private final Predicate perCharacter; + private final LegacyRecipient recipients; + private final Logger logger; + + public CharacterTracker(PlayerManager players, Predicate perCharacter, LegacyRecipient recipients, + Logger logger) { + this.players = players; + this.perCharacter = perCharacter; + this.recipients = recipients; + this.logger = logger; + } /** The character the player is playing, or empty between characters. */ public Optional character(UUID account) { @@ -53,7 +72,33 @@ public void onQuit(PlayerQuitEvent event) { } private void begin(CharacterRef ref) { + seed(ref.ownerId()); current.put(ref.ownerId(), ref.characterId()); } + /** + * Once per account, main thread only, to the character pinned for the account (see + * {@link LegacyRecipient}). Waits for a later session if no character can be chosen yet. + */ + void seed(UUID owner) { + PlayerData data = players.getPlayerByUUID(owner); + if (data == null || data.getSeededTo() != null) { + return; + } + Optional heir; + try { + heir = recipients.of(owner, () -> MostPlayed.of(Characters.characters(owner))); + } catch (IOException | RuntimeException ex) { + logger.log(Level.SEVERE, "Could not pin the most-played character of account " + owner + + "; trying again at its next session", ex); + return; + } + heir.ifPresent(character -> { + int moved = data.seed(character, perCharacter); + if (moved > 0) { + logger.info("Gave " + moved + " old per-character event records of account " + owner + + " to its most-played character " + character); + } + }); + } } diff --git a/src/main/java/net/tfminecraft/conditionalevents/character/LegacyRecipient.java b/src/main/java/net/tfminecraft/conditionalevents/character/LegacyRecipient.java new file mode 100644 index 0000000..0a61a1e --- /dev/null +++ b/src/main/java/net/tfminecraft/conditionalevents/character/LegacyRecipient.java @@ -0,0 +1,69 @@ +package net.tfminecraft.conditionalevents.character; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Supplier; + +/** + * The one character that receives an account's data from before characters had their own, pinned + * in {@code plugins/TLibs/legacy-recipients/.txt}. Codex and ConditionalEvents both read + * this file, so they pick the same character, and retries never pick another. Main thread only. + */ +public final class LegacyRecipient { + static final String FOLDER = "TLibs/legacy-recipients"; + + private final Path folder; + + public LegacyRecipient(Path pluginsFolder) { + this.folder = pluginsFolder.resolve(FOLDER); + } + + /** + * The pinned character; if none is pinned yet, pins and returns the chosen one. Empty when none + * is pinned and nobody can be chosen yet. + */ + public Optional of(UUID owner, Supplier> choose) throws IOException { + Path file = folder.resolve(owner + ".txt"); + Optional pinned = read(file); + if (pinned.isPresent()) { + return pinned; + } + boolean unreadable = Files.exists(file); + Optional chosen = choose.get(); + if (chosen.isEmpty()) { + return chosen; + } + Files.createDirectories(folder); + Path temporary = folder.resolve(owner + ".txt.tmp"); + Files.writeString(temporary, chosen.get().toString(), StandardCharsets.UTF_8); + try { + if (unreadable) { + Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } else { + // Without REPLACE_EXISTING the move refuses a pin written meanwhile. + Files.move(temporary, file); + } + } catch (FileAlreadyExistsException pinnedMeanwhile) { + Files.deleteIfExists(temporary); + return read(file).or(() -> chosen); + } + return chosen; + } + + private static Optional read(Path file) throws IOException { + if (!Files.isRegularFile(file)) { + return Optional.empty(); + } + try { + return Optional.of(UUID.fromString(Files.readString(file, StandardCharsets.UTF_8).trim())); + } catch (IllegalArgumentException unreadable) { + return Optional.empty(); + } + } +} diff --git a/src/main/java/net/tfminecraft/conditionalevents/character/MostPlayed.java b/src/main/java/net/tfminecraft/conditionalevents/character/MostPlayed.java new file mode 100644 index 0000000..21a3c70 --- /dev/null +++ b/src/main/java/net/tfminecraft/conditionalevents/character/MostPlayed.java @@ -0,0 +1,29 @@ +package net.tfminecraft.conditionalevents.character; + +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import net.tfminecraft.tlibs.character.CharacterInfo; + +/** The account's most-played living character, else its most-played character. */ +final class MostPlayed { + private static final Comparator PLAYTIME = Comparator + .comparingLong(CharacterInfo::playtimeSeconds) + .thenComparing(info -> info.lastPlayedAt() == null ? Instant.MIN : info.lastPlayedAt()); + + private MostPlayed() { + } + + /** Empty when the roster is unknown or the account has no character. */ + static Optional of(Optional> roster) { + List all = roster.orElse(List.of()); + Optional best = all.stream().filter(CharacterInfo::alive).max(PLAYTIME); + if (best.isEmpty()) { + best = all.stream().max(PLAYTIME); + } + return best.map(info -> info.ref().characterId()); + } +} diff --git a/src/main/java/net/tfminecraft/conditionalevents/configs/MainConfigManager.java b/src/main/java/net/tfminecraft/conditionalevents/configs/MainConfigManager.java index 7250bea..6457cab 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/configs/MainConfigManager.java +++ b/src/main/java/net/tfminecraft/conditionalevents/configs/MainConfigManager.java @@ -22,7 +22,6 @@ public class MainConfigManager { private boolean experimentalVariableReplacement; private boolean itemMetaVariableEnabled; private boolean useMiniMessage; - private boolean retainPlayerDataUntilRestart; private ArrayList toConditionGroups; public MainConfigManager(ConditionalEvents plugin){ this.plugin = plugin; @@ -38,7 +37,6 @@ public void configure(){ experimentalVariableReplacement = config.getBoolean("Config.experimental.variable_replacement"); itemMetaVariableEnabled = config.getBoolean("Config.item_meta_variable_enabled"); useMiniMessage = config.getBoolean("Config.use_minimessage"); - retainPlayerDataUntilRestart = config.getBoolean("Config.retain_player_data_until_restart"); toConditionGroups = new ArrayList<>(); String path = "Config.to_condition_groups"; if(config.contains(path)){ @@ -85,8 +83,7 @@ public void checkMessagesUpdate(){ Path pathConfig = Paths.get(configFile.getFullPath()); try{ String text = new String(Files.readAllBytes(pathConfig)); - if(!text.contains("retain_player_data_until_restart:")){ - getConfig().set("Config.retain_player_data_until_restart",true); + if(!text.contains("playerHasNotPlayedRecentlyOrNoData:")){ getConfig().set("Messages.playerHasNotPlayedRecentlyOrNoData","&cThat player has not played recently or doesn't have any data."); saveConfig(); } @@ -208,7 +205,4 @@ public boolean isUseMiniMessage() { return useMiniMessage; } - public boolean isRetainPlayerDataUntilRestart() { - return retainPlayerDataUntilRestart; - } } diff --git a/src/main/java/net/tfminecraft/conditionalevents/configs/PlayersConfigsManager.java b/src/main/java/net/tfminecraft/conditionalevents/configs/PlayersConfigsManager.java index 942d6df..51963c0 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/configs/PlayersConfigsManager.java +++ b/src/main/java/net/tfminecraft/conditionalevents/configs/PlayersConfigsManager.java @@ -6,12 +6,20 @@ import net.tfminecraft.conditionalevents.model.player.GenericCallback; import net.tfminecraft.conditionalevents.model.player.PlayerData; import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.scheduler.BukkitRunnable; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.logging.Level; public class PlayersConfigsManager extends DataFolderConfigManager{ @@ -65,6 +73,14 @@ public void run() { static PlayerData read(UUID uuid, ConfigurationSection config){ PlayerData playerData = new PlayerData(uuid,config.getString("name")); playerData.setEventData(readEvents(config.getConfigurationSection("events"))); + String seeded = config.getString("seeded-to"); + if(seeded != null){ + try{ + playerData.setSeededTo(UUID.fromString(seeded)); + }catch(IllegalArgumentException notCharacter){ + // Not a character ID: treat as not seeded. + } + } ConfigurationSection characters = config.getConfigurationSection("characters"); if(characters != null){ for(String key : characters.getKeys(false)){ @@ -92,6 +108,7 @@ static void write(PlayerData.Snapshot player, ConfigurationSection players){ players.set("name", player.name()); players.set("events", null); players.set("characters", null); + players.set("seeded-to", player.seededTo() == null ? null : player.seededTo().toString()); writeEvents(players, "events", player.account()); for(Map.Entry> character : player.characters().entrySet()){ writeEvents(players, "characters."+character.getKey()+".events", character.getValue()); @@ -106,16 +123,35 @@ private static void writeEvents(ConfigurationSection players, String path, List< } /** Writes a snapshot; changes made meanwhile keep the data marked modified for the next save. */ - public void saveConfig(PlayerData player){ + /** + * Writes a snapshot through a temporary file. The data is marked clean only when the write + * succeeded and nothing changed meanwhile; a failure is logged and the data stays modified, + * so the next save tries again. + * @return whether the file was written + */ + public boolean saveConfig(PlayerData player){ synchronized(player.saveLock()){ PlayerData.Snapshot snapshot = player.snapshot(); CommonConfig playerConfig = getConfigFile(player.getUuid().toString()+".yml",true); write(snapshot, playerConfig.getConfig()); - playerConfig.saveConfig(); + Path file = plugin.getDataFolder().toPath().resolve(folderName).resolve(player.getUuid()+".yml"); + try{ + writeAtomically(file, playerConfig.getConfig().saveToString()); + }catch(IOException e){ + plugin.getLogger().log(Level.SEVERE, "Could not save ConditionalEvents data of "+player.getUuid()+"; retrying with the next save", e); + return false; + } player.markSaved(snapshot.revision()); + return true; } } + static void writeAtomically(Path file, String text) throws IOException{ + Path temporary = file.resolveSibling(file.getFileName()+".tmp"); + Files.writeString(temporary, text, StandardCharsets.UTF_8); + Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } + @Override public void saveConfigs() { Map players = plugin.getPlayerManager().getPlayers(); @@ -127,11 +163,42 @@ public void saveConfigs() { } } + private final Object filesLock = new Object(); + + /** Held while loading an account into memory and while resetting the files of accounts not in memory. */ + public Object filesLock(){ + return filesLock; + } + + /** + * Resets the files of accounts not in memory, atomically. Accounts in memory are skipped here: + * they are reset in memory and saved as usual, so a reset never writes over a newer save. + */ public void resetDataForAllPlayers(String eventName){ - ArrayList configs = getConfigs(); - for(CommonConfig commonConfig : configs) { - if(reset(commonConfig.getConfig(), eventName)){ - commonConfig.saveConfig(); + Path folder = plugin.getDataFolder().toPath().resolve(folderName); + synchronized(filesLock){ + File[] files = folder.toFile().listFiles((dir, name) -> name.endsWith(".yml")); + if(files == null){ + return; + } + for(File file : files){ + UUID uuid; + try{ + uuid = UUID.fromString(file.getName().substring(0, file.getName().length()-4)); + }catch(IllegalArgumentException notAccount){ + continue; + } + if(plugin.getPlayerManager().getPlayerByUUID(uuid) != null){ + continue; + } + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(file); + if(reset(yaml, eventName)){ + try{ + writeAtomically(file.toPath(), yaml.saveToString()); + }catch(IOException e){ + plugin.getLogger().log(Level.SEVERE, "Could not reset ConditionalEvents data of "+uuid, e); + } + } } } } diff --git a/src/main/java/net/tfminecraft/conditionalevents/managers/PlayerManager.java b/src/main/java/net/tfminecraft/conditionalevents/managers/PlayerManager.java index c703377..161aa53 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/managers/PlayerManager.java +++ b/src/main/java/net/tfminecraft/conditionalevents/managers/PlayerManager.java @@ -168,11 +168,11 @@ public void run() { public void run() { String result; if(eventName.equals("all")) { - // All data, retained: + // All data, in memory: players.values().forEach(PlayerData::resetAll); result = messagesConfig.getString("Messages.eventDataResetAllForAllPlayers"); }else { - // Specific event, retained: + // Specific event, in memory: players.values().forEach(p -> p.resetEvent(eventName)); result = messagesConfig.getString("Messages.eventDataResetForAllPlayers") .replace("%event%", eventName); @@ -193,22 +193,20 @@ public void manageJoin(AsyncPlayerPreLoginEvent event){ UUID uuid = event.getUniqueId(); String playerName = event.getName(); - // Load player data from file if exists - PlayerData playerData = null; - boolean alreadyOnMemory = false; - if(plugin.getConfigsManager().getMainConfigManager().isRetainPlayerDataUntilRestart()){ - // Try to get retained data + // Data in memory is newer than the file. Loading holds the files lock, so a staff reset of + // offline files cannot interleave with an account being loaded. + PlayersConfigsManager files = plugin.getConfigsManager().getPlayerConfigsManager(); + PlayerData playerData; + synchronized(files.filesLock()){ playerData = getPlayerByUUID(uuid); - alreadyOnMemory = playerData != null; - } - if(playerData == null){ - // Try to get file data - playerData = plugin.getConfigsManager().getPlayerConfigsManager().loadConfig(uuid); + if(playerData == null){ + playerData = files.loadConfig(uuid); + if(playerData != null){ + addPlayer(playerData); + } + } } if(playerData != null){ - if(!alreadyOnMemory){ - addPlayer(playerData); - } if(playerData.getName() == null || !playerData.getName().equals(playerName)){ updatePlayerName(playerData.getName(),playerName,uuid); playerData.setName(playerName); @@ -221,22 +219,16 @@ public void manageLeave(Player player, PlayerQuitEvent event){ new ConditionEvent(plugin, player, event, EventType.PLAYER_LEAVE, null) .checkEvent(); - // Save player data into file and remove from map + // Save player data into file. It stays in memory until restart, so a rejoin always finds the + // newest data and a failed save is retried by the periodic save. PlayerData playerData = getPlayer(player,false); - if(playerData != null){ - if(playerData.isModified()){ - new BukkitRunnable(){ - @Override - public void run() { - plugin.getConfigsManager().getPlayerConfigsManager().saveConfig(playerData); - } - }.runTaskAsynchronously(plugin); - } - - // Retain player data if enabled - if(!plugin.getConfigsManager().getMainConfigManager().isRetainPlayerDataUntilRestart()){ - removePlayer(playerData); - } + if(playerData != null && playerData.isModified()){ + new BukkitRunnable(){ + @Override + public void run() { + plugin.getConfigsManager().getPlayerConfigsManager().saveConfig(playerData); + } + }.runTaskAsynchronously(plugin); } } } diff --git a/src/main/java/net/tfminecraft/conditionalevents/model/player/PlayerData.java b/src/main/java/net/tfminecraft/conditionalevents/model/player/PlayerData.java index f4077c3..71b5535 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/model/player/PlayerData.java +++ b/src/main/java/net/tfminecraft/conditionalevents/model/player/PlayerData.java @@ -2,9 +2,11 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.function.Predicate; /** * An account's event data. Events with {@code data_scope: character} keep theirs per character; @@ -18,6 +20,7 @@ public class PlayerData { private String name; private boolean modified; private long revision; + private UUID seededTo; private long resetAllCount; private final Map resetCounts = new HashMap<>(); private ArrayList eventData; @@ -32,7 +35,8 @@ public PlayerData(UUID uuid, String name) { } /** A copy of the data to write, with the revision it was taken at. */ - public record Snapshot(String name, List account, Map> characters, long revision) { + public record Snapshot(String name, List account, Map> characters, UUID seededTo, + long revision) { } public UUID getUuid() { @@ -65,10 +69,51 @@ public synchronized void setCharacterEventData(UUID character, ArrayList events) { + if (seededTo != null) { + return -1; + } + int moved = 0; + Iterator account = eventData.iterator(); + while (account.hasNext()) { + EventData e = account.next(); + if (!events.test(e.getName())) { + continue; + } + EventData own = find(character, e.getName()); + if (own == null) { + writable(character).add(new EventData(e.getName(), e.getCooldown(), e.isOneTime())); + } else { + own.setOneTime(own.isOneTime() || e.isOneTime()); + own.setCooldown(Math.max(own.getCooldown(), e.getCooldown())); + } + account.remove(); + moved++; + } + seededTo = character; + touched(); + return moved; + } + public synchronized Snapshot snapshot() { Map> characters = new HashMap<>(); characterEventData.forEach((character, events) -> characters.put(character, copy(events))); - return new Snapshot(name, copy(eventData), characters, revision); + return new Snapshot(name, copy(eventData), characters, seededTo, revision); } private static List copy(List events) { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 0d35cd3..37ea42d 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,6 +1,5 @@ Config: data_save_time: 5 - retain_player_data_until_restart: true debug_actions: true experimental: variable_replacement: false diff --git a/src/test/java/net/tfminecraft/conditionalevents/character/CharacterTrackerTest.java b/src/test/java/net/tfminecraft/conditionalevents/character/CharacterTrackerTest.java index bb5bc13..5bbccad 100644 --- a/src/test/java/net/tfminecraft/conditionalevents/character/CharacterTrackerTest.java +++ b/src/test/java/net/tfminecraft/conditionalevents/character/CharacterTrackerTest.java @@ -1,5 +1,20 @@ package net.tfminecraft.conditionalevents.character; +import org.junit.jupiter.api.io.TempDir; +import java.nio.file.Path; +import java.nio.file.Files; +import net.tfminecraft.tlibs.character.CharacterStatus; +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.conditionalevents.model.player.PlayerData; +import net.tfminecraft.conditionalevents.managers.PlayerManager; +import net.tfminecraft.conditionalevents.ConditionalEvents; +import java.util.logging.Logger; +import java.util.logging.LogRecord; +import java.util.logging.Handler; +import java.util.Set; +import java.util.ArrayList; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -25,17 +40,39 @@ import net.tfminecraft.tlibs.character.Characters; class CharacterTrackerTest { + @TempDir + Path plugins; + private final UUID owner = UUID.randomUUID(); private final UUID other = UUID.randomUUID(); private final UUID alt = UUID.randomUUID(); private MockedStatic characters; + private final List logs = new ArrayList<>(); + private PlayerManager players; private CharacterTracker tracker; private Player player; @BeforeEach void setUp() { characters = mockStatic(Characters.class); - tracker = new CharacterTracker(); + players = new PlayerManager(mock(ConditionalEvents.class)); + Logger logger = Logger.getAnonymousLogger(); + logger.setUseParentHandlers(false); + logger.addHandler(new Handler() { + @Override + public void publish(LogRecord record) { + logs.add(record); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }); + tracker = new CharacterTracker(players, Set.of("research_bronze")::contains, new LegacyRecipient(plugins), logger); player = mock(Player.class); when(player.getUniqueId()).thenReturn(owner); } @@ -68,6 +105,78 @@ void quittingForgetsTheCharacter() { assertTrue(tracker.character(owner).isEmpty()); } + @Test + void theMostPlayedCharacterGetsTheOldPerCharacterDataOnce() { + PlayerData data = new PlayerData(owner, "Steve"); + data.setOneTime("research_bronze", true); + data.setCooldown("global_booster", 900); + players.addPlayer(data); + characters.when(() -> Characters.characters(owner)).thenReturn(Optional.of(List.of( + played(other, 9000), played(alt, 100)))); + + tracker.onStart(start(alt)); + + assertTrue(data.isEventOneTime(other, "research_bronze")); + assertFalse(data.isEventOneTime(alt, "research_bronze")); + assertFalse(data.isEventOneTime("research_bronze")); + assertEquals(900, data.getCooldown("global_booster")); + assertEquals(other, data.getSeededTo()); + assertTrue(logs.stream().anyMatch(r -> r.getMessage().contains("Gave 1 old per-character event records"))); + + data.setOneTime("research_bronze", true); + tracker.onStart(start(alt)); + assertTrue(data.isEventOneTime("research_bronze")); + } + + @Test + void thePinnedCharacterWinsOverTodaysMostPlayed() throws Exception { + UUID pinned = UUID.randomUUID(); + Files.createDirectories(plugins.resolve("TLibs/legacy-recipients")); + Files.writeString(plugins.resolve("TLibs/legacy-recipients/" + owner + ".txt"), pinned.toString()); + PlayerData data = new PlayerData(owner, "Steve"); + data.setOneTime("research_bronze", true); + players.addPlayer(data); + characters.when(() -> Characters.characters(owner)).thenReturn(Optional.of(List.of(played(alt, 9000)))); + + tracker.onStart(start(alt)); + + assertTrue(data.isEventOneTime(pinned, "research_bronze")); + assertFalse(data.isEventOneTime(alt, "research_bronze")); + } + + @Test + void aPinThatCannotBeWrittenWaitsForTheNextSession() throws Exception { + Files.writeString(plugins.resolve("TLibs"), "not a folder"); + PlayerData data = new PlayerData(owner, "Steve"); + players.addPlayer(data); + characters.when(() -> Characters.characters(owner)).thenReturn(Optional.of(List.of(played(alt, 5)))); + + tracker.onStart(start(alt)); + + assertNull(data.getSeededTo()); + assertTrue(logs.stream().anyMatch(r -> r.getMessage().contains("Could not pin the most-played character"))); + } + + @Test + void noSeedWithoutDataOrRosterAndNothingToMoveIsQuiet() { + tracker.onStart(start(alt)); + + PlayerData data = new PlayerData(owner, "Steve"); + players.addPlayer(data); + characters.when(() -> Characters.characters(owner)).thenReturn(Optional.empty()); + tracker.onStart(start(alt)); + assertNull(data.getSeededTo()); + + characters.when(() -> Characters.characters(owner)).thenReturn(Optional.of(List.of(played(alt, 5)))); + tracker.onStart(start(alt)); + assertEquals(alt, data.getSeededTo()); + assertTrue(logs.isEmpty()); + } + + private CharacterInfo played(UUID character, long seconds) { + return new CharacterInfo(new CharacterRef(owner, character), "Name", CharacterStatus.ALIVE, null, Set.of(), seconds, null); + } + @Test void startPicksUpCharactersAlreadyInPlay() { characters.when(() -> Characters.currentSession(player)) diff --git a/src/test/java/net/tfminecraft/conditionalevents/character/LegacyRecipientTest.java b/src/test/java/net/tfminecraft/conditionalevents/character/LegacyRecipientTest.java new file mode 100644 index 0000000..137c62e --- /dev/null +++ b/src/test/java/net/tfminecraft/conditionalevents/character/LegacyRecipientTest.java @@ -0,0 +1,71 @@ +package net.tfminecraft.conditionalevents.character; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class LegacyRecipientTest { + @TempDir + Path plugins; + + private final UUID owner = UUID.randomUUID(); + private final UUID aldric = UUID.randomUUID(); + private final UUID mira = UUID.randomUUID(); + + @Test + void theFirstChoiceIsPinnedForGood() throws IOException { + LegacyRecipient recipients = new LegacyRecipient(plugins); + + assertEquals(Optional.of(aldric), recipients.of(owner, () -> Optional.of(aldric))); + assertEquals(Optional.of(aldric), recipients.of(owner, () -> Optional.of(mira))); + assertEquals(aldric.toString(), Files.readString(pin())); + assertFalse(Files.exists(pin().resolveSibling(owner + ".txt.tmp"))); + } + + @Test + void nobodyToChooseLeavesNoPin() throws IOException { + assertEquals(Optional.empty(), new LegacyRecipient(plugins).of(owner, Optional::empty)); + assertFalse(Files.exists(pin())); + } + + @Test + void anUnreadablePinIsReplaced() throws IOException { + Files.createDirectories(pin().getParent()); + Files.writeString(pin(), "garbled"); + + assertEquals(Optional.of(mira), new LegacyRecipient(plugins).of(owner, () -> Optional.of(mira))); + assertEquals(mira.toString(), Files.readString(pin())); + } + + @Test + void aPinWrittenMeanwhileWins() throws IOException { + LegacyRecipient recipients = new LegacyRecipient(plugins); + + Optional result = recipients.of(owner, () -> { + try { + Files.createDirectories(pin().getParent()); + Files.writeString(pin(), aldric.toString()); + } catch (IOException ex) { + throw new IllegalStateException(ex); + } + return Optional.of(mira); + }); + + assertEquals(Optional.of(aldric), result); + assertTrue(Files.exists(pin())); + assertFalse(Files.exists(pin().resolveSibling(owner + ".txt.tmp"))); + } + + private Path pin() { + return plugins.resolve("TLibs").resolve("legacy-recipients").resolve(owner + ".txt"); + } +} diff --git a/src/test/java/net/tfminecraft/conditionalevents/character/MostPlayedTest.java b/src/test/java/net/tfminecraft/conditionalevents/character/MostPlayedTest.java new file mode 100644 index 0000000..b1138b4 --- /dev/null +++ b/src/test/java/net/tfminecraft/conditionalevents/character/MostPlayedTest.java @@ -0,0 +1,51 @@ +package net.tfminecraft.conditionalevents.character; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterStatus; + +class MostPlayedTest { + private final UUID owner = UUID.randomUUID(); + + @Test + void theMostPlayedLivingCharacterWins() { + CharacterInfo dead = info(CharacterStatus.DEAD, 9000, null); + CharacterInfo less = info(CharacterStatus.ALIVE, 100, Instant.parse("2026-10-01T00:00:00Z")); + CharacterInfo tied = info(CharacterStatus.ALIVE, 500, null); + CharacterInfo later = info(CharacterStatus.ALIVE, 500, Instant.parse("2026-10-02T00:00:00Z")); + + assertEquals(Optional.of(id(later)), MostPlayed.of(Optional.of(List.of(dead, less, tied, later)))); + } + + @Test + void anAllDeadAccountFallsBackToItsMostPlayed() { + CharacterInfo less = info(CharacterStatus.DEAD, 10, null); + CharacterInfo more = info(CharacterStatus.MISSING, 20, null); + + assertEquals(Optional.of(id(more)), MostPlayed.of(Optional.of(List.of(less, more)))); + } + + @Test + void noRosterOrNoCharacterMeansNobody() { + assertEquals(Optional.empty(), MostPlayed.of(Optional.empty())); + assertEquals(Optional.empty(), MostPlayed.of(Optional.of(List.of()))); + } + + private CharacterInfo info(CharacterStatus status, long playtime, Instant lastPlayed) { + return new CharacterInfo(new CharacterRef(owner, UUID.randomUUID()), "Name", status, null, Set.of(), playtime, lastPlayed); + } + + private static UUID id(CharacterInfo info) { + return info.ref().characterId(); + } +} diff --git a/src/test/java/net/tfminecraft/conditionalevents/configs/PlayersConfigsManagerTest.java b/src/test/java/net/tfminecraft/conditionalevents/configs/PlayersConfigsManagerTest.java index ce92743..8266612 100644 --- a/src/test/java/net/tfminecraft/conditionalevents/configs/PlayersConfigsManagerTest.java +++ b/src/test/java/net/tfminecraft/conditionalevents/configs/PlayersConfigsManagerTest.java @@ -31,6 +31,14 @@ void characterEventsRoundTripBesideAccountEvents() throws Exception { assertEquals(900, loaded.getCooldown("global_booster")); assertTrue(loaded.isEventOneTime(character, "research_bronze")); assertFalse(loaded.isEventOneTime("research_bronze")); + assertEquals(null, loaded.getSeededTo()); + + data.seed(character, name -> false); + YamlConfiguration seeded = new YamlConfiguration(); + PlayersConfigsManager.write(data.snapshot(), seeded); + assertEquals(character, PlayersConfigsManager.read(account, seeded).getSeededTo()); + seeded.set("seeded-to", "nonsense"); + assertEquals(null, PlayersConfigsManager.read(account, seeded).getSeededTo()); } @Test @@ -89,4 +97,63 @@ void resetWithoutCharactersOnlyTouchesTheAccount() throws Exception { assertFalse(PlayersConfigsManager.reset(yaml, "all")); } + + @org.junit.jupiter.api.io.TempDir + java.nio.file.Path dataFolder; + + @Test + void aFailedSaveKeepsTheDataModifiedAndASuccessfulOneIsAtomic() throws Exception { + net.tfminecraft.conditionalevents.ConditionalEvents plugin = org.mockito.Mockito.mock(net.tfminecraft.conditionalevents.ConditionalEvents.class); + org.mockito.Mockito.when(plugin.getDataFolder()).thenReturn(dataFolder.toFile()); + org.mockito.Mockito.when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + PlayersConfigsManager manager = new PlayersConfigsManager(plugin, "players"); + java.nio.file.Files.createDirectories(dataFolder.resolve("players")); + PlayerData data = new PlayerData(account, "Steve"); + data.seed(character, name -> false); + java.nio.file.Path blocker = dataFolder.resolve("players").resolve(account + ".yml.tmp"); + java.nio.file.Files.createDirectories(blocker); + + assertFalse(manager.saveConfig(data)); + assertTrue(data.isModified()); + + java.nio.file.Files.delete(blocker); + assertTrue(manager.saveConfig(data)); + assertFalse(data.isModified()); + YamlConfiguration saved = YamlConfiguration.loadConfiguration(dataFolder.resolve("players").resolve(account + ".yml").toFile()); + assertEquals(character.toString(), saved.getString("seeded-to")); + } + + @Test + void resetAllLeavesFilesOfLoadedAccountsToTheirNextSave() throws Exception { + net.tfminecraft.conditionalevents.ConditionalEvents plugin = org.mockito.Mockito.mock(net.tfminecraft.conditionalevents.ConditionalEvents.class); + org.mockito.Mockito.when(plugin.getDataFolder()).thenReturn(dataFolder.toFile()); + org.mockito.Mockito.when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + net.tfminecraft.conditionalevents.managers.PlayerManager players = new net.tfminecraft.conditionalevents.managers.PlayerManager(plugin); + org.mockito.Mockito.when(plugin.getPlayerManager()).thenReturn(players); + PlayersConfigsManager manager = new PlayersConfigsManager(plugin, "players"); + java.nio.file.Path folder = java.nio.file.Files.createDirectories(dataFolder.resolve("players")); + UUID offline = UUID.randomUUID(); + String done = "name: X\nevents:\n codex_die:\n one_time: true\n"; + java.nio.file.Files.writeString(folder.resolve(account + ".yml"), done); + java.nio.file.Files.writeString(folder.resolve(offline + ".yml"), done); + java.nio.file.Files.writeString(folder.resolve("notes.yml"), done); + java.nio.file.Files.writeString(folder.resolve(UUID.randomUUID() + ".yml"), "name: Y\n"); + players.addPlayer(new PlayerData(account, "Steve")); + + manager.resetDataForAllPlayers("codex_die"); + + assertTrue(java.nio.file.Files.readString(folder.resolve(account + ".yml")).contains("codex_die")); + assertFalse(java.nio.file.Files.readString(folder.resolve(offline + ".yml")).contains("codex_die")); + assertTrue(java.nio.file.Files.readString(folder.resolve("notes.yml")).contains("codex_die")); + assertTrue(manager.filesLock() != null); + + java.nio.file.Files.createDirectories(folder.resolve(offline + ".yml.tmp")); + java.nio.file.Files.writeString(folder.resolve(offline + ".yml"), done); + manager.resetDataForAllPlayers("codex_die"); + assertTrue(java.nio.file.Files.readString(folder.resolve(offline + ".yml")).contains("codex_die")); + + java.nio.file.Files.delete(folder.resolve(offline + ".yml.tmp")); + org.mockito.Mockito.when(plugin.getDataFolder()).thenReturn(dataFolder.resolve("missing").toFile()); + manager.resetDataForAllPlayers("codex_die"); + } }