diff --git a/README.md b/README.md index aa1aceb..30a8131 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,12 @@ ConditionalEvents runs actions when Minecraft events meet configured conditions: commands, item use, joins, deaths, regions and more. Events can run once only or -on a cooldown. This copy tracks upstream 4.80.3 and builds for Paper 1.21.10. It -has no update checker or bStats reporting. +on a cooldown. Once-only and cooldown data belong to the roleplay character in +play, so such events run only while a character is in play; `data_scope: account` +keeps an event's data per account instead. Events without that data run as +upstream. This copy tracks upstream 4.80.3, builds for +Paper 1.21.10 and needs TLibs 2.6.1 with RPCharacters. It has no update checker or +bStats reporting. ## Documentation @@ -18,6 +22,9 @@ covers event types, conditions, actions and variables. With Java 21 and the private build inputs prepared by `.github/scripts/prepare-release.sh`, run `mvn clean verify`. +JUnit and Mockito cover the per-character data, which must keep 100% line +coverage in `net.tfminecraft.conditionalevents.character`. Upstream +ConditionalEvents has no tests. ## License diff --git a/pom.xml b/pom.xml index 0f3d7fb..815b8c4 100644 --- a/pom.xml +++ b/pom.xml @@ -15,6 +15,8 @@ 21 UTF-8 + + 2.6.1 @@ -83,6 +85,31 @@ 2.13.0 provided + + me.plugins + tlibs + ${tlibs.version} + provided + + + + * + * + + + + + org.junit.jupiter + junit-jupiter + 5.14.0 + test + + + org.mockito + mockito-core + 5.20.0 + test + @@ -158,6 +185,71 @@ maven-compiler-plugin 3.14.1 + + org.apache.maven.plugins + maven-dependency-plugin + 3.7.0 + + + + properties + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + @{argLine} -javaagent:${org.mockito:mockito-core:jar} -Xshare:off + true + + + + org.jacoco + jacoco-maven-plugin + 0.8.15 + + + + prepare-agent + + + + coverage-report + verify + + report + + + + coverage-check + verify + + check + + + + + + PACKAGE + + net.tfminecraft.conditionalevents.character + + + + LINE + MISSEDCOUNT + 0 + + + + + + + + org.apache.maven.plugins maven-shade-plugin diff --git a/src/main/java/net/tfminecraft/conditionalevents/ConditionalEvents.java b/src/main/java/net/tfminecraft/conditionalevents/ConditionalEvents.java index 3c547d8..7a466b0 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/ConditionalEvents.java +++ b/src/main/java/net/tfminecraft/conditionalevents/ConditionalEvents.java @@ -1,6 +1,6 @@ package net.tfminecraft.conditionalevents; - +import net.tfminecraft.conditionalevents.character.CharacterTracker; import net.tfminecraft.conditionalevents.api.ConditionalEventsAPI; import net.tfminecraft.conditionalevents.api.ExpansionCE; import net.tfminecraft.conditionalevents.configs.ConfigsManager; @@ -35,6 +35,7 @@ public class ConditionalEvents extends JavaPlugin { private EventsManager eventsManager; private DependencyManager dependencyManager; private PlayerManager playerManager; + private CharacterTracker characterTracker; private ConfigsManager configsManager; private DebugManager debugManager; private BungeeMessagingManager bungeeMessagingManager; @@ -62,6 +63,7 @@ public void onEnable(){ this.interruptEventManager = new InterruptEventManager(this); this.configsManager = new ConfigsManager(this); this.configsManager.configure(); + this.characterTracker = new CharacterTracker(); registerEvents(); registerCommands(); @@ -74,6 +76,7 @@ public void onEnable(){ + characterTracker.start(); reloadPlayerDataSaveTask(); ConditionalEventsAPI api = new ConditionalEventsAPI(this); @@ -97,6 +100,7 @@ public void onDisable(){ public void registerEvents() { PluginManager pm = getServer().getPluginManager(); pm.registerEvents(new PlayerEventsListener(this), this); + pm.registerEvents(characterTracker, this); pm.registerEvents(new ItemEventsListener(this), this); pm.registerEvents(new ArmorListener(new ArrayList<>()), this); pm.registerEvents(new ItemSelectListener(this), this); @@ -223,6 +227,10 @@ public BungeeMessagingManager getBungeeMessagingManager() { return bungeeMessagingManager; } + public CharacterTracker getCharacterTracker() { + return characterTracker; + } + public PlayerManager getPlayerManager() { return playerManager; } diff --git a/src/main/java/net/tfminecraft/conditionalevents/character/CharacterTracker.java b/src/main/java/net/tfminecraft/conditionalevents/character/CharacterTracker.java new file mode 100644 index 0000000..af79f7d --- /dev/null +++ b/src/main/java/net/tfminecraft/conditionalevents/character/CharacterTracker.java @@ -0,0 +1,59 @@ +package net.tfminecraft.conditionalevents.character; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerQuitEvent; + +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSessionEndEvent; +import net.tfminecraft.tlibs.character.CharacterSessionStartEvent; +import net.tfminecraft.tlibs.character.Characters; + +/** + * Which character each player is playing, readable from any thread, since events such as chat run + * off the main thread. + */ +public final class CharacterTracker implements Listener { + private final Map current = new ConcurrentHashMap<>(); + + /** The character the player is playing, or empty between characters. */ + public Optional character(UUID account) { + return Optional.ofNullable(current.get(account)); + } + + /** Picks up characters already in play when the plugin enables. Main thread only. */ + public void start() { + for (Player player : Bukkit.getOnlinePlayers()) { + Characters.currentSession(player).ifPresent(session -> begin(session.ref())); + } + } + + @EventHandler(priority = EventPriority.LOWEST) + public void onStart(CharacterSessionStartEvent event) { + begin(event.getSession().ref()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onEnd(CharacterSessionEndEvent event) { + CharacterRef ref = event.getSession().ref(); + current.remove(ref.ownerId(), ref.characterId()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onQuit(PlayerQuitEvent event) { + current.remove(event.getPlayer().getUniqueId()); + } + + private void begin(CharacterRef ref) { + current.put(ref.ownerId(), ref.characterId()); + } + +} diff --git a/src/main/java/net/tfminecraft/conditionalevents/configs/ConfigsManager.java b/src/main/java/net/tfminecraft/conditionalevents/configs/ConfigsManager.java index d4799c2..8b7120f 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/configs/ConfigsManager.java +++ b/src/main/java/net/tfminecraft/conditionalevents/configs/ConfigsManager.java @@ -64,6 +64,7 @@ public void configureEvents(){ List conditions = new ArrayList(); List actionGroups = new ArrayList(); boolean oneTime = false; + boolean characterScoped = true; String ignoreWithPermission = null; long cooldown = 0; boolean enabled = true; @@ -167,6 +168,13 @@ public void configureEvents(){ if(config.contains(path+".one_time")) { oneTime = Boolean.valueOf(config.getString(path+".one_time")); } + if(config.contains(path+".data_scope")) { + String scope = config.getString(path+".data_scope"); + characterScoped = !"account".equalsIgnoreCase(scope); + if(characterScoped && !"character".equalsIgnoreCase(scope)){ + plugin.getLogger().warning("Event "+key+" has data_scope "+scope+"; use character or account. Using character."); + } + } if(config.contains(path+".enabled")) { enabled = Boolean.valueOf(config.getString(path+".enabled")); } @@ -190,6 +198,7 @@ public void configureEvents(){ event.setCooldown(cooldown); event.setIgnoreWithPermission(ignoreWithPermission); event.setOneTime(oneTime); + event.setCharacterScoped(characterScoped); event.setEnabled(enabled); event.setIgnoreIfCancelled(ignoreIfCancelled); event.setPreventCooldownActivationActionGroups(preventCooldownActivationActionGroups); diff --git a/src/main/java/net/tfminecraft/conditionalevents/configs/PlayersConfigsManager.java b/src/main/java/net/tfminecraft/conditionalevents/configs/PlayersConfigsManager.java index bc5df89..942d6df 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/configs/PlayersConfigsManager.java +++ b/src/main/java/net/tfminecraft/conditionalevents/configs/PlayersConfigsManager.java @@ -5,10 +5,11 @@ import net.tfminecraft.conditionalevents.model.player.EventData; import net.tfminecraft.conditionalevents.model.player.GenericCallback; import net.tfminecraft.conditionalevents.model.player.PlayerData; -import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.ConfigurationSection; import org.bukkit.scheduler.BukkitRunnable; import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.UUID; @@ -32,22 +33,7 @@ public PlayerData loadConfig(UUID uuid){ PlayerData playerData = null; CommonConfig playerConfig = getConfigFile(uuid+".yml",false); if(playerConfig != null){ - // If config exists - FileConfiguration config = playerConfig.getConfig(); - String name = config.getString("name"); - - playerData = new PlayerData(uuid,name); - ArrayList eventData = new ArrayList<>(); - if(config.contains("events")){ - for(String key : config.getConfigurationSection("events").getKeys(false)){ - boolean oneTime = config.getBoolean("events."+key+".one_time"); - long cooldown = config.getLong("events."+key+".cooldown"); - EventData event = new EventData(key,cooldown,oneTime); - - eventData.add(event); - } - } - playerData.setEventData(eventData); + playerData = read(uuid,playerConfig.getConfig()); } return playerData; @@ -60,22 +46,7 @@ public void run() { PlayerData playerData = null; CommonConfig playerConfig = getConfigFile(uuid+".yml",false); if(playerConfig != null){ - // If config exists - FileConfiguration config = playerConfig.getConfig(); - String name = config.getString("name"); - - playerData = new PlayerData(uuid,name); - ArrayList eventData = new ArrayList<>(); - if(config.contains("events")){ - for(String key : config.getConfigurationSection("events").getKeys(false)){ - boolean oneTime = config.getBoolean("events."+key+".one_time"); - long cooldown = config.getLong("events."+key+".cooldown"); - EventData event = new EventData(key,cooldown,oneTime); - - eventData.add(event); - } - } - playerData.setEventData(eventData); + playerData = read(uuid,playerConfig.getConfig()); } PlayerData finalPlayer = playerData; @@ -90,21 +61,59 @@ public void run() { }.runTaskAsynchronously(plugin); } - public void saveConfig(PlayerData player){ - String playerName = player.getName(); - CommonConfig playerConfig = getConfigFile(player.getUuid().toString()+".yml",true); - FileConfiguration players = playerConfig.getConfig(); + /** Account events under {@code events}, per-character events under {@code characters..events}. */ + static PlayerData read(UUID uuid, ConfigurationSection config){ + PlayerData playerData = new PlayerData(uuid,config.getString("name")); + playerData.setEventData(readEvents(config.getConfigurationSection("events"))); + ConfigurationSection characters = config.getConfigurationSection("characters"); + if(characters != null){ + for(String key : characters.getKeys(false)){ + try{ + playerData.setCharacterEventData(UUID.fromString(key),readEvents(characters.getConfigurationSection(key+".events"))); + }catch(IllegalArgumentException notCharacter){ + // Not a character ID. + } + } + } + return playerData; + } + + private static ArrayList readEvents(ConfigurationSection events){ + ArrayList eventData = new ArrayList<>(); + if(events != null){ + for(String key : events.getKeys(false)){ + eventData.add(new EventData(key,events.getLong(key+".cooldown"),events.getBoolean(key+".one_time"))); + } + } + return eventData; + } - players.set("name", playerName); + static void write(PlayerData.Snapshot player, ConfigurationSection players){ + players.set("name", player.name()); players.set("events", null); + players.set("characters", null); + writeEvents(players, "events", player.account()); + for(Map.Entry> character : player.characters().entrySet()){ + writeEvents(players, "characters."+character.getKey()+".events", character.getValue()); + } + } - for(EventData event : player.getEventData()){ - String path = "events."+event.getName(); - players.set(path+".one_time", event.isOneTime()); - players.set(path+".cooldown", event.getCooldown()); + private static void writeEvents(ConfigurationSection players, String path, List events){ + for(EventData event : events){ + players.set(path+"."+event.getName()+".one_time", event.isOneTime()); + players.set(path+"."+event.getName()+".cooldown", event.getCooldown()); } + } - playerConfig.saveConfig(); + /** Writes a snapshot; changes made meanwhile keep the data marked modified for the next save. */ + public void 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(); + player.markSaved(snapshot.revision()); + } } @Override @@ -115,24 +124,36 @@ public void saveConfigs() { if(playerData.isModified()){ saveConfig(playerData); } - playerData.setModified(false); } } public void resetDataForAllPlayers(String eventName){ ArrayList configs = getConfigs(); for(CommonConfig commonConfig : configs) { - FileConfiguration config = commonConfig.getConfig(); - if(eventName.equals("all")){ - config.set("events",null); - }else{ - if(!config.contains("events."+eventName)){ - continue; - } - config.set("events."+eventName,null); + if(reset(commonConfig.getConfig(), eventName)){ + commonConfig.saveConfig(); } + } + } - commonConfig.saveConfig(); + /** Clears the event, or all events, for the account and its characters. Returns whether anything changed. */ + static boolean reset(ConfigurationSection config, String eventName){ + List paths = new ArrayList<>(); + paths.add("events"); + ConfigurationSection characters = config.getConfigurationSection("characters"); + if(characters != null){ + for(String key : characters.getKeys(false)){ + paths.add("characters."+key+".events"); + } + } + boolean changed = false; + for(String path : paths){ + String target = eventName.equals("all") ? path : path+"."+eventName; + if(config.contains(target)){ + config.set(target,null); + changed = true; + } } + return changed; } } diff --git a/src/main/java/net/tfminecraft/conditionalevents/managers/EventsManager.java b/src/main/java/net/tfminecraft/conditionalevents/managers/EventsManager.java index e5e1476..300bba1 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/managers/EventsManager.java +++ b/src/main/java/net/tfminecraft/conditionalevents/managers/EventsManager.java @@ -16,6 +16,7 @@ import org.bukkit.event.Event; import java.util.ArrayList; +import java.util.UUID; import java.util.List; public class EventsManager { @@ -61,6 +62,18 @@ public void checkSingularEvent(ConditionEvent conditionEvent,CEEvent event){ return; } + // Events keeping per-character data need a character in play, fixed here for the whole run. + // Without a player there is no data to keep, as upstream. + UUID character = null; + CharacterRun run = null; + if(player != null && event.usesCharacterData()){ + character = plugin.getPlayerManager().playedCharacter(player); + if(character == null){ + return; + } + run = new CharacterRun(character); + } + //Ignore if cancelled if(event.isIgnoreIfCancelled()){ Event minecraftEvent = conditionEvent.getMinecraftEvent(); @@ -89,11 +102,12 @@ public void checkSingularEvent(ConditionEvent conditionEvent,CEEvent event){ //Check One time if(event.isOneTime()){ - boolean isOneTime = playerManager.getEventOneTime(event.getName(),player); + boolean isOneTime = playerManager.getEventOneTime(event.getName(),player,character); if(isOneTime){ ExecutedEvent executedEvent = new ExecutedEvent(player, conditionEvent.getEventVariables(), event, "one_time", conditionEvent.getMinecraftEvent(), conditionEvent.getTarget(), plugin, conditionEvent.getAdditionalEventStorage()); + executedEvent.bindToCharacter(run); plugin.getDebugManager().sendActionsMessage(event.getName(),"one_time",player); executedEvent.executeActions(); return; @@ -102,7 +116,7 @@ public void checkSingularEvent(ConditionEvent conditionEvent,CEEvent event){ //Check Cooldown if(event.getCooldown() != 0 && !bypassCooldown){ - long eventCooldownMillis = playerManager.getEventCooldown(event.getName(),player)+(event.getCooldown()*1000); + long eventCooldownMillis = playerManager.getEventCooldown(event.getName(),player,character)+(event.getCooldown()*1000); long currentTimeMillis = System.currentTimeMillis(); if(eventCooldownMillis > currentTimeMillis){ String timeString = TimeUtils.getTime((eventCooldownMillis-currentTimeMillis)/1000,messagesManager); @@ -110,6 +124,7 @@ public void checkSingularEvent(ConditionEvent conditionEvent,CEEvent event){ ExecutedEvent executedEvent = new ExecutedEvent(player, conditionEvent.getEventVariables(), event, "cooldown", conditionEvent.getMinecraftEvent(), conditionEvent.getTarget(), plugin, conditionEvent.getAdditionalEventStorage()); + executedEvent.bindToCharacter(run); plugin.getDebugManager().sendActionsMessage(event.getName(),"cooldown",player); executedEvent.executeActions(); return; @@ -128,14 +143,22 @@ public void checkSingularEvent(ConditionEvent conditionEvent,CEEvent event){ //Set One Time if(event.isOneTime()){ if(!event.getPreventOneTimeActivationActionGroups().contains(actionGroupWithoutParameters)){ - playerManager.setEventOneTime(event.getName(),player); + playerManager.setEventOneTime(event.getName(),player,character,true); + if(run != null){ + run.markedOneTime(playerManager.getPlayer(player,true).getResetEpoch(event.getName())); + } } } //Set Cooldown if(event.getCooldown() != 0 && !bypassCooldown){ if(!event.getPreventCooldownActivationActionGroups().contains(actionGroupWithoutParameters)){ - playerManager.setEventCooldown(event.getName(),player); + long previous = playerManager.getEventCooldown(event.getName(),player,character); + long now = System.currentTimeMillis(); + playerManager.setEventCooldown(event.getName(),player,character,now); + if(run != null){ + run.markedCooldown(previous,now,playerManager.getPlayer(player,true).getResetEpoch(event.getName())); + } } } } @@ -144,6 +167,7 @@ public void checkSingularEvent(ConditionEvent conditionEvent,CEEvent event){ ExecutedEvent executedEvent = new ExecutedEvent(player, conditionEvent.getEventVariables(), event, executeActionGroup, conditionEvent.getMinecraftEvent(), conditionEvent.getTarget(), plugin, conditionEvent.getAdditionalEventStorage()); + executedEvent.bindToCharacter(run); executedEvent.executeActions(); } diff --git a/src/main/java/net/tfminecraft/conditionalevents/managers/PlayerManager.java b/src/main/java/net/tfminecraft/conditionalevents/managers/PlayerManager.java index 36a0e98..c703377 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/managers/PlayerManager.java +++ b/src/main/java/net/tfminecraft/conditionalevents/managers/PlayerManager.java @@ -1,6 +1,7 @@ package net.tfminecraft.conditionalevents.managers; import net.tfminecraft.conditionalevents.ConditionalEvents; +import net.tfminecraft.conditionalevents.model.CEEvent; import net.tfminecraft.conditionalevents.configs.PlayersConfigsManager; import net.tfminecraft.conditionalevents.model.EventType; import net.tfminecraft.conditionalevents.model.internal.ConditionEvent; @@ -75,34 +76,66 @@ public void removePlayer(PlayerData playerData){ } + /** Where an event's data lives for this player: the account, or the played character. */ + private record Scope(boolean available, UUID character) { + static final Scope ACCOUNT = new Scope(true, null); + } + + private Scope scope(String eventName, Player player){ + CEEvent event = plugin.getEventsManager().getEvent(eventName); + if(event == null || !event.usesCharacterData()){ + return Scope.ACCOUNT; + } + UUID character = playedCharacter(player); + return new Scope(character != null, character); + } + + /** The character the player is playing, or null between characters. */ + public UUID playedCharacter(Player player){ + return plugin.getCharacterTracker().character(player.getUniqueId()).orElse(null); + } + public void setEventCooldown(String eventName,Player player){ - PlayerData p = getPlayer(player,true); - p.setCooldown(eventName,System.currentTimeMillis()); - p.setModified(true); + Scope scope = scope(eventName,player); + if(scope.available()){ + setEventCooldown(eventName,player,scope.character(),System.currentTimeMillis()); + } + } + + /** {@code character} null means the account's data. */ + public void setEventCooldown(String eventName,Player player,UUID character,long millis){ + getPlayer(player,true).setCooldown(character,eventName,millis); } public long getEventCooldown(String eventName,Player player){ + Scope scope = scope(eventName,player); + return scope.available() ? getEventCooldown(eventName,player,scope.character()) : 0; + } + + public long getEventCooldown(String eventName,Player player,UUID character){ PlayerData p = getPlayer(player,false); - if(p == null){ - return 0; - }else{ - return p.getCooldown(eventName); - } + return p == null ? 0 : p.getCooldown(character,eventName); } public void setEventOneTime(String eventName,Player player){ - PlayerData p = getPlayer(player,true); - p.setOneTime(eventName,true); - p.setModified(true); + Scope scope = scope(eventName,player); + if(scope.available()){ + setEventOneTime(eventName,player,scope.character(),true); + } + } + + public void setEventOneTime(String eventName,Player player,UUID character,boolean oneTime){ + getPlayer(player,true).setOneTime(character,eventName,oneTime); } public boolean getEventOneTime(String eventName,Player player){ + Scope scope = scope(eventName,player); + return scope.available() && getEventOneTime(eventName,player,scope.character()); + } + + public boolean getEventOneTime(String eventName,Player player,UUID character){ PlayerData p = getPlayer(player,false); - if(p == null){ - return false; - }else{ - return p.isEventOneTime(eventName); - } + return p != null && p.isEventOneTime(character,eventName); } public String resetDataForPlayer(String playerName, String eventName, FileConfiguration messagesConfig){ diff --git a/src/main/java/net/tfminecraft/conditionalevents/model/CEEvent.java b/src/main/java/net/tfminecraft/conditionalevents/model/CEEvent.java index b8ca79b..55e2bec 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/model/CEEvent.java +++ b/src/main/java/net/tfminecraft/conditionalevents/model/CEEvent.java @@ -15,6 +15,7 @@ public class CEEvent { private List actionGroups; private boolean oneTime; + private boolean characterScoped = true; private String ignoreWithPermission; @@ -73,6 +74,24 @@ public boolean isOneTime() { return oneTime; } + /** Whether the event's data belongs to the played character: {@code data_scope: character}, the default. */ + public boolean isCharacterScoped() { + return characterScoped; + } + + /** + * Whether runs depend on the played character: the event is character-scoped and keeps data + * ({@code one_time} or a cooldown). Events without data have nothing to keep per character, so + * they run as upstream, with or without a character in play. + */ + public boolean usesCharacterData() { + return characterScoped && (oneTime || cooldown != 0); + } + + public void setCharacterScoped(boolean characterScoped) { + this.characterScoped = characterScoped; + } + public void setOneTime(boolean oneTime) { this.oneTime = oneTime; } diff --git a/src/main/java/net/tfminecraft/conditionalevents/model/internal/CharacterRun.java b/src/main/java/net/tfminecraft/conditionalevents/model/internal/CharacterRun.java new file mode 100644 index 0000000..a8d4293 --- /dev/null +++ b/src/main/java/net/tfminecraft/conditionalevents/model/internal/CharacterRun.java @@ -0,0 +1,59 @@ +package net.tfminecraft.conditionalevents.model.internal; + +import java.util.UUID; + +import net.tfminecraft.conditionalevents.model.player.PlayerData; + +/** + * One admitted run of a per-character event, shared by the run and every action group it calls. + * It records the character the run belongs to and the marks it set, so a run whose character has + * left can be dropped once and undo exactly those marks. + */ +public final class CharacterRun { + private final UUID character; + private boolean markedOneTime; + private Long previousCooldown; + private long setCooldown; + private long resetEpoch; + private boolean abandoned; + + public CharacterRun(UUID character) { + this.character = character; + } + + public UUID character() { + return character; + } + + /** Records the one-time flag this run set, under the data's current reset epoch. */ + public void markedOneTime(long resetEpoch) { + this.markedOneTime = true; + this.resetEpoch = resetEpoch; + } + + /** Records the cooldown start this run set and the value it replaced. */ + public void markedCooldown(long previous, long set, long resetEpoch) { + this.previousCooldown = previous; + this.setCooldown = set; + this.resetEpoch = resetEpoch; + } + + public boolean isAbandoned() { + return abandoned; + } + + /** + * Drops the run and undoes its marks in the loaded data, if any. Returns false if it was already + * dropped. Data no longer loaded is left alone rather than recreated. + */ + public synchronized boolean abandon(PlayerData data, String eventName) { + if (abandoned) { + return false; + } + abandoned = true; + if (data != null && (markedOneTime || previousCooldown != null)) { + data.undoRun(character, eventName, markedOneTime, previousCooldown, setCooldown, resetEpoch); + } + return true; + } +} diff --git a/src/main/java/net/tfminecraft/conditionalevents/model/internal/ExecutedEvent.java b/src/main/java/net/tfminecraft/conditionalevents/model/internal/ExecutedEvent.java index 30d97d1..25d9bad 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/model/internal/ExecutedEvent.java +++ b/src/main/java/net/tfminecraft/conditionalevents/model/internal/ExecutedEvent.java @@ -3,6 +3,7 @@ import net.tfminecraft.conditionalevents.ConditionalEvents; import net.tfminecraft.conditionalevents.api.ConditionalEventsEvent; import net.tfminecraft.conditionalevents.managers.DebugManager; +import net.tfminecraft.conditionalevents.managers.PlayerManager; import net.tfminecraft.conditionalevents.managers.EventsManager; import net.tfminecraft.conditionalevents.model.CEEvent; import net.tfminecraft.conditionalevents.model.StoredVariable; @@ -39,6 +40,9 @@ public class ExecutedEvent { private boolean onWait; private int currentActionPos; + // Per-character runs share one record with the action groups they call. + private CharacterRun characterRun; + public ExecutedEvent(Player player, ArrayList eventVariables, CEEvent event, String actionGroupName , Event minecraftEvent, LivingEntity target, ConditionalEvents plugin, AdditionalEventStorage additionalEventStorage) { this.player = player; @@ -131,12 +135,47 @@ public void executeCancelActions(){ } } + /** + * Ties a per-character run, and the action groups it calls, to the character it started for. + * If that character is no longer in play when an action is due (after a wait, or when an async + * event hands over to the main thread), the rest is dropped and the marks the run set are + * undone, so the character can earn it again. Null leaves the run unbound. + */ + public void bindToCharacter(CharacterRun characterRun){ + this.characterRun = characterRun; + } + + public CharacterRun getCharacterRun(){ + return characterRun; + } + + /** Whether the run may carry on; drops it when its character has left. */ + boolean stillForItsCharacter(){ + if(characterRun == null){ + return true; + } + if(characterRun.isAbandoned()){ + return false; + } + PlayerManager playerManager = plugin.getPlayerManager(); + if(characterRun.character().equals(playerManager.playedCharacter(player))){ + return true; + } + if(characterRun.abandon(playerManager.getPlayer(player,false),event.getName())){ + plugin.getDebugManager().sendActionsMessage(event.getName(),"abandoned: character left",player); + } + return false; + } + public void continueWithActions(){ this.onWait = false; executeActionsFinal(); } private void executeActionsFinal(){ + if(!stillForItsCharacter()){ + return; + } DebugManager debugManager = plugin.getDebugManager(); boolean isDebugActions = plugin.getConfigsManager().getMainConfigManager().isDebugActions(); VariablesProperties variablesProperties = generateVariableProperties(); 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 b69d5da..f4077c3 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/model/player/PlayerData.java +++ b/src/main/java/net/tfminecraft/conditionalevents/model/player/PlayerData.java @@ -1,13 +1,28 @@ package net.tfminecraft.conditionalevents.model.player; import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.UUID; +/** + * An account's event data. Events with {@code data_scope: character} keep theirs per character; + * methods taking a {@code character} use the account's own list when it is null. + * + *

Events run on the main thread and on async threads while saves run async, so every access + * locks this object and saves work from a {@link #snapshot()}. + */ public class PlayerData { private UUID uuid; private String name; private boolean modified; + private long revision; + private long resetAllCount; + private final Map resetCounts = new HashMap<>(); private ArrayList eventData; + private final Map> characterEventData = new HashMap<>(); + private final Object saveLock = new Object(); public PlayerData(UUID uuid, String name) { this.uuid = uuid; @@ -16,6 +31,10 @@ public PlayerData(UUID uuid, String name) { this.modified = false; } + /** 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 UUID getUuid() { return uuid; } @@ -24,24 +43,59 @@ public void setUuid(UUID uuid) { this.uuid = uuid; } - public String getName() { + public synchronized String getName() { return name; } - public void setName(String name) { + public synchronized void setName(String name) { this.name = name; } - public ArrayList getEventData() { - return eventData; + /** Held while writing this account's file, so two saves never write it at once. */ + public Object saveLock() { + return saveLock; } - public void setEventData(ArrayList eventData) { + public synchronized void setEventData(ArrayList eventData) { this.eventData = eventData; } - public EventData getEventData(String eventName){ - for(EventData e : eventData){ + /** Loading only. */ + public synchronized void setCharacterEventData(UUID character, ArrayList events) { + characterEventData.put(character, events); + } + + 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); + } + + private static List copy(List events) { + List copy = new ArrayList<>(); + for (EventData e : events) { + copy.add(new EventData(e.getName(), e.getCooldown(), e.isOneTime())); + } + return copy; + } + + /** Marks the data clean only if nothing changed since the snapshot was taken. */ + public synchronized void markSaved(long savedRevision) { + if (savedRevision == revision) { + modified = false; + } + } + + private ArrayList writable(UUID character) { + return character == null ? eventData : characterEventData.computeIfAbsent(character, ignored -> new ArrayList<>()); + } + + private EventData find(UUID character, String eventName) { + List events = character == null ? eventData : characterEventData.get(character); + if(events == null){ + return null; + } + for(EventData e : events){ if(e.getName().equals(eventName)){ return e; } @@ -49,59 +103,108 @@ public EventData getEventData(String eventName){ return null; } - public void resetEvent(String eventName){ - eventData.removeIf(c -> c.getName().equals(eventName)); + private void touched() { + revision++; modified = true; } - public void resetAll(){ + /** + * Changes whenever staff reset this event or all events, so an older run never undoes marks + * made after such a reset. Resets of other events leave it alone. + */ + public synchronized long getResetEpoch(String eventName){ + return resetAllCount + resetCounts.getOrDefault(eventName, 0L); + } + + /** + * Undoes what one abandoned run set, only where it still holds: the one-time flag unless the + * event was reset since, and the cooldown only if it is still the start this run wrote. + */ + public synchronized void undoRun(UUID character, String eventName, boolean oneTime, Long previousCooldown, + long setCooldown, long epoch){ + EventData e = find(character, eventName); + if(e == null || epoch != getResetEpoch(eventName)){ + return; + } + if(oneTime){ + e.setOneTime(false); + } + if(previousCooldown != null && e.getCooldown() == setCooldown){ + e.setCooldown(previousCooldown); + } + touched(); + } + + /** Resets the event for the account and every character. */ + public synchronized void resetEvent(String eventName){ + resetCounts.merge(eventName, 1L, Long::sum); + eventData.removeIf(c -> c.getName().equals(eventName)); + characterEventData.values().forEach(list -> list.removeIf(c -> c.getName().equals(eventName))); + touched(); + } + + public synchronized void resetAll(){ + resetAllCount++; eventData.clear(); - modified = true; + characterEventData.clear(); + touched(); } public void setCooldown(String eventName,long currentMillis){ - EventData e = getEventData(eventName); + setCooldown(null, eventName, currentMillis); + } + + public synchronized void setCooldown(UUID character, String eventName, long currentMillis){ + EventData e = find(character, eventName); if(e == null){ - e = new EventData(eventName,currentMillis,false); - eventData.add(e); + writable(character).add(new EventData(eventName,currentMillis,false)); }else{ e.setCooldown(currentMillis); } + touched(); } public long getCooldown(String eventName){ - EventData e = getEventData(eventName); - if(e == null){ - return 0; - }else{ - return e.getCooldown(); - } + return getCooldown(null, eventName); + } + + public synchronized long getCooldown(UUID character, String eventName){ + EventData e = find(character, eventName); + return e == null ? 0 : e.getCooldown(); } public void setOneTime(String eventName,boolean oneTime){ - EventData e = getEventData(eventName); + setOneTime(null, eventName, oneTime); + } + + public synchronized void setOneTime(UUID character, String eventName, boolean oneTime){ + EventData e = find(character, eventName); if(e == null){ - e = new EventData(eventName,0,oneTime); - eventData.add(e); + writable(character).add(new EventData(eventName,0,oneTime)); }else{ e.setOneTime(oneTime); } + touched(); } public boolean isEventOneTime(String eventName){ - EventData e = getEventData(eventName); - if(e == null){ - return false; - }else{ - return e.isOneTime(); - } + return isEventOneTime(null, eventName); + } + + public synchronized boolean isEventOneTime(UUID character, String eventName){ + EventData e = find(character, eventName); + return e != null && e.isOneTime(); } - public boolean isModified() { + public synchronized boolean isModified() { return modified; } - public void setModified(boolean modified) { - this.modified = modified; + public synchronized void setModified(boolean modified) { + if(modified){ + touched(); + }else{ + this.modified = false; + } } } diff --git a/src/main/java/net/tfminecraft/conditionalevents/utils/ActionUtils.java b/src/main/java/net/tfminecraft/conditionalevents/utils/ActionUtils.java index 6939a22..cb4cc36 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/utils/ActionUtils.java +++ b/src/main/java/net/tfminecraft/conditionalevents/utils/ActionUtils.java @@ -1114,8 +1114,8 @@ public static void executeActionGroup(String actionLine,ExecutedEvent executedEv String actionGroup = MathUtils.getRandomKeyByWeight(weights); - //Clone ExecutedEvent - new ExecutedEvent( + //Clone ExecutedEvent; it belongs to the same character run, if any + ExecutedEvent group = new ExecutedEvent( executedEvent.getPlayer(), executedEvent.getEventVariables(), executedEvent.getEvent(), @@ -1124,7 +1124,9 @@ public static void executeActionGroup(String actionLine,ExecutedEvent executedEv executedEvent.getTarget(), plugin, executedEvent.getAdditionalEventStorage() - ).executeActions(); + ); + group.bindToCharacter(executedEvent.getCharacterRun()); + group.executeActions(); } public static void setEventXp(String actionLine,Event minecraftEvent){ diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 4645221..4dc58d8 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -2,6 +2,7 @@ main: net.tfminecraft.conditionalevents.ConditionalEvents version: ${project.version} name: ConditionalEvents api-version: '1.21.10' +depend: [TLibs] softdepend: [PlaceholderAPI,Citizens,WorldGuardEvents,WorldGuard,ProtocolLib,DiscordSRV,ItemsAdder,PacketEvents] author: Ajneb97 diff --git a/src/test/java/net/tfminecraft/conditionalevents/character/CharacterTrackerTest.java b/src/test/java/net/tfminecraft/conditionalevents/character/CharacterTrackerTest.java new file mode 100644 index 0000000..bb5bc13 --- /dev/null +++ b/src/test/java/net/tfminecraft/conditionalevents/character/CharacterTrackerTest.java @@ -0,0 +1,92 @@ +package net.tfminecraft.conditionalevents.character; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerQuitEvent; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.CharacterSessionEndEvent; +import net.tfminecraft.tlibs.character.CharacterSessionStartEvent; +import net.tfminecraft.tlibs.character.Characters; + +class CharacterTrackerTest { + private final UUID owner = UUID.randomUUID(); + private final UUID other = UUID.randomUUID(); + private final UUID alt = UUID.randomUUID(); + private MockedStatic characters; + private CharacterTracker tracker; + private Player player; + + @BeforeEach + void setUp() { + characters = mockStatic(Characters.class); + tracker = new CharacterTracker(); + player = mock(Player.class); + when(player.getUniqueId()).thenReturn(owner); + } + + @AfterEach + void tearDown() { + characters.close(); + } + + @Test + void tracksThePlayedCharacterAcrossSwitches() { + assertTrue(tracker.character(owner).isEmpty()); + + tracker.onStart(start(alt)); + assertEquals(Optional.of(alt), tracker.character(owner)); + + tracker.onEnd(end(other)); + assertEquals(Optional.of(alt), tracker.character(owner)); + + tracker.onEnd(end(alt)); + assertTrue(tracker.character(owner).isEmpty()); + } + + @Test + void quittingForgetsTheCharacter() { + tracker.onStart(start(alt)); + + tracker.onQuit(new PlayerQuitEvent(player, "bye")); + + assertTrue(tracker.character(owner).isEmpty()); + } + + @Test + void startPicksUpCharactersAlreadyInPlay() { + characters.when(() -> Characters.currentSession(player)) + .thenReturn(Optional.of(new CharacterSession(new CharacterRef(owner, alt), 1))); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(player)); + + tracker.start(); + } + + assertEquals(Optional.of(alt), tracker.character(owner)); + } + + private CharacterSessionStartEvent start(UUID character) { + return new CharacterSessionStartEvent(player, new CharacterSession(new CharacterRef(owner, character), 1)); + } + + private CharacterSessionEndEvent end(UUID character) { + return new CharacterSessionEndEvent(player, new CharacterSession(new CharacterRef(owner, character), 1), + CharacterSessionEndEvent.Reason.SWITCH); + } +} diff --git a/src/test/java/net/tfminecraft/conditionalevents/configs/PlayersConfigsManagerTest.java b/src/test/java/net/tfminecraft/conditionalevents/configs/PlayersConfigsManagerTest.java new file mode 100644 index 0000000..ce92743 --- /dev/null +++ b/src/test/java/net/tfminecraft/conditionalevents/configs/PlayersConfigsManagerTest.java @@ -0,0 +1,92 @@ +package net.tfminecraft.conditionalevents.configs; + +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.util.UUID; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.conditionalevents.model.player.PlayerData; + +class PlayersConfigsManagerTest { + private final UUID account = UUID.randomUUID(); + private final UUID character = UUID.randomUUID(); + + @Test + void characterEventsRoundTripBesideAccountEvents() throws Exception { + PlayerData data = new PlayerData(account, "Steve"); + data.setCooldown("global_booster", 900); + data.setOneTime(character, "research_bronze", true); + YamlConfiguration yaml = new YamlConfiguration(); + + PlayersConfigsManager.write(data.snapshot(), yaml); + YamlConfiguration reread = new YamlConfiguration(); + reread.loadFromString(yaml.saveToString()); + PlayerData loaded = PlayersConfigsManager.read(account, reread); + + assertEquals("Steve", loaded.getName()); + assertEquals(900, loaded.getCooldown("global_booster")); + assertTrue(loaded.isEventOneTime(character, "research_bronze")); + assertFalse(loaded.isEventOneTime("research_bronze")); + } + + @Test + void upstreamFilesStillLoadAndStrayCharacterKeysAreIgnored() throws Exception { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.loadFromString(""" + name: Steve + events: + codex_die: + one_time: true + cooldown: 0 + characters: + notes: + events: + x: + one_time: true + %s: + other: 1 + """.formatted(character)); + + PlayerData loaded = PlayersConfigsManager.read(account, yaml); + + assertTrue(loaded.isEventOneTime("codex_die")); + assertEquals(1, loaded.snapshot().characters().size()); + assertTrue(loaded.snapshot().characters().get(character).isEmpty()); + } + + @Test + void resetClearsTheEventForTheAccountAndEveryCharacter() throws Exception { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.loadFromString(""" + name: Steve + events: + codex_die: {one_time: true} + codex_tf: {one_time: true} + characters: + %s: + events: + codex_die: {one_time: true} + """.formatted(character)); + + assertTrue(PlayersConfigsManager.reset(yaml, "codex_die")); + assertFalse(yaml.contains("events.codex_die")); + assertFalse(yaml.contains("characters." + character + ".events.codex_die")); + assertTrue(yaml.contains("events.codex_tf")); + assertFalse(PlayersConfigsManager.reset(yaml, "codex_die")); + + assertTrue(PlayersConfigsManager.reset(yaml, "all")); + assertFalse(yaml.contains("events")); + } + + @Test + void resetWithoutCharactersOnlyTouchesTheAccount() throws Exception { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.loadFromString("name: Steve\n"); + + assertFalse(PlayersConfigsManager.reset(yaml, "all")); + } +} diff --git a/src/test/java/net/tfminecraft/conditionalevents/managers/PlayerManagerScopeTest.java b/src/test/java/net/tfminecraft/conditionalevents/managers/PlayerManagerScopeTest.java new file mode 100644 index 0000000..13e95a0 --- /dev/null +++ b/src/test/java/net/tfminecraft/conditionalevents/managers/PlayerManagerScopeTest.java @@ -0,0 +1,93 @@ +package net.tfminecraft.conditionalevents.managers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import java.util.UUID; + +import org.bukkit.entity.Player; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.conditionalevents.ConditionalEvents; +import net.tfminecraft.conditionalevents.character.CharacterTracker; +import net.tfminecraft.conditionalevents.model.CEEvent; +import net.tfminecraft.conditionalevents.model.player.PlayerData; + +class PlayerManagerScopeTest { + private final UUID owner = UUID.randomUUID(); + private final UUID character = UUID.randomUUID(); + private CharacterTracker tracker; + private PlayerManager players; + private Player player; + + @BeforeEach + void setUp() { + ConditionalEvents plugin = mock(ConditionalEvents.class); + EventsManager events = mock(EventsManager.class); + tracker = mock(CharacterTracker.class); + when(plugin.getEventsManager()).thenReturn(events); + when(plugin.getCharacterTracker()).thenReturn(tracker); + CEEvent research = new CEEvent("research_bronze"); + research.setOneTime(true); + CEEvent booster = new CEEvent("global_booster"); + booster.setOneTime(true); + booster.setCharacterScoped(false); + when(events.getEvent("research_bronze")).thenReturn(research); + when(events.getEvent("global_booster")).thenReturn(booster); + players = new PlayerManager(plugin); + player = mock(Player.class); + when(player.getUniqueId()).thenReturn(owner); + when(player.getName()).thenReturn("Steve"); + when(tracker.character(owner)).thenReturn(Optional.empty()); + } + + @Test + void perCharacterEventsHaveNoDataBetweenCharacters() { + assertNull(players.playedCharacter(player)); + + players.setEventOneTime("research_bronze", player); + players.setEventCooldown("research_bronze", player); + + assertFalse(players.getEventOneTime("research_bronze", player)); + assertEquals(0, players.getEventCooldown("research_bronze", player)); + assertNull(players.getPlayer(player, false)); + } + + @Test + void perCharacterEventsUseThePlayedCharactersData() { + when(tracker.character(owner)).thenReturn(Optional.of(character)); + + players.setEventOneTime("research_bronze", player); + players.setEventCooldown("research_bronze", player); + players.setEventOneTime("global_booster", player); + + PlayerData data = players.getPlayer(player, false); + assertEquals(character, players.playedCharacter(player)); + assertTrue(players.getEventOneTime("research_bronze", player)); + assertTrue(players.getEventCooldown("research_bronze", player) > 0); + assertTrue(data.isEventOneTime(character, "research_bronze")); + assertFalse(data.isEventOneTime("research_bronze")); + assertTrue(data.isEventOneTime("global_booster")); + assertTrue(players.getEventOneTime("global_booster", player)); + assertEquals(0, players.getEventCooldown("unknown_event", player)); + } + + @Test + void explicitCharacterCallsIgnoreWhoIsPlayingNow() { + UUID other = UUID.randomUUID(); + when(tracker.character(owner)).thenReturn(Optional.of(other)); + + players.setEventOneTime("research_bronze", player, character, true); + players.setEventCooldown("research_bronze", player, character, 42); + + assertTrue(players.getEventOneTime("research_bronze", player, character)); + assertEquals(42, players.getEventCooldown("research_bronze", player, character)); + assertFalse(players.getEventOneTime("research_bronze", player)); + } +} diff --git a/src/test/java/net/tfminecraft/conditionalevents/model/CEEventScopeTest.java b/src/test/java/net/tfminecraft/conditionalevents/model/CEEventScopeTest.java new file mode 100644 index 0000000..518a0b5 --- /dev/null +++ b/src/test/java/net/tfminecraft/conditionalevents/model/CEEventScopeTest.java @@ -0,0 +1,26 @@ +package net.tfminecraft.conditionalevents.model; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class CEEventScopeTest { + @Test + void characterIsTheDefaultButOnlyMattersForEventsWithData() { + CEEvent tip = new CEEvent("join_tip"); + assertTrue(tip.isCharacterScoped()); + assertFalse(tip.usesCharacterData()); + + CEEvent unlock = new CEEvent("codex_die"); + unlock.setOneTime(true); + assertTrue(unlock.usesCharacterData()); + + CEEvent meditate = new CEEvent("meditate"); + meditate.setCooldown(10); + assertTrue(meditate.usesCharacterData()); + + meditate.setCharacterScoped(false); + assertFalse(meditate.usesCharacterData()); + } +} diff --git a/src/test/java/net/tfminecraft/conditionalevents/model/internal/ExecutedEventCharacterTest.java b/src/test/java/net/tfminecraft/conditionalevents/model/internal/ExecutedEventCharacterTest.java new file mode 100644 index 0000000..37dabd5 --- /dev/null +++ b/src/test/java/net/tfminecraft/conditionalevents/model/internal/ExecutedEventCharacterTest.java @@ -0,0 +1,171 @@ +package net.tfminecraft.conditionalevents.model.internal; + +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.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.Optional; +import java.util.UUID; + +import org.bukkit.entity.Player; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.conditionalevents.ConditionalEvents; +import net.tfminecraft.conditionalevents.character.CharacterTracker; +import net.tfminecraft.conditionalevents.managers.DebugManager; +import net.tfminecraft.conditionalevents.managers.EventsManager; +import net.tfminecraft.conditionalevents.managers.PlayerManager; +import net.tfminecraft.conditionalevents.model.CEEvent; +import net.tfminecraft.conditionalevents.model.player.PlayerData; + +class ExecutedEventCharacterTest { + private static final String EVENT = "codex_poi100"; + private final UUID owner = UUID.randomUUID(); + private final UUID aldric = UUID.randomUUID(); + private final UUID mira = UUID.randomUUID(); + private ConditionalEvents plugin; + private CharacterTracker tracker; + private PlayerManager players; + private Player player; + private CEEvent poi100; + + @BeforeEach + void setUp() { + plugin = mock(ConditionalEvents.class); + tracker = mock(CharacterTracker.class); + when(plugin.getCharacterTracker()).thenReturn(tracker); + when(plugin.getEventsManager()).thenReturn(mock(EventsManager.class)); + when(plugin.getDebugManager()).thenReturn(mock(DebugManager.class)); + players = new PlayerManager(plugin); + when(plugin.getPlayerManager()).thenReturn(players); + player = mock(Player.class); + when(player.getUniqueId()).thenReturn(owner); + when(player.getName()).thenReturn("Steve"); + poi100 = new CEEvent(EVENT); + poi100.setOneTime(true); + when(tracker.character(owner)).thenReturn(Optional.of(aldric)); + } + + @Test + void aRunCarriesOnWhileItsCharacterPlays() { + ExecutedEvent run = run(new CharacterRun(aldric)); + + assertTrue(run.stillForItsCharacter()); + } + + @Test + void switchingDuringAWaitDropsTheRunAndItsGroupsAndUndoesItsMarksOnce() { + CharacterRun record = admit(100L, 500L); + ExecutedEvent parent = run(record); + ExecutedEvent group = run(parent.getCharacterRun()); + when(tracker.character(owner)).thenReturn(Optional.of(mira)); + + assertFalse(group.stillForItsCharacter()); + assertFalse(parent.stillForItsCharacter()); + + assertFalse(players.getEventOneTime(EVENT, player, aldric)); + assertEquals(100, players.getEventCooldown(EVENT, player, aldric)); + assertFalse(players.getEventOneTime(EVENT, player, mira)); + assertSame(record, group.getCharacterRun()); + } + + @Test + void anOldRunLeavesANewerCooldownAlone() { + CharacterRun first = admit(0L, 1000L); + players.setEventCooldown(EVENT, player, aldric, 2000); + when(tracker.character(owner)).thenReturn(Optional.of(mira)); + + assertFalse(run(first).stillForItsCharacter()); + + assertEquals(2000, players.getEventCooldown(EVENT, player, aldric)); + assertFalse(players.getEventOneTime(EVENT, player, aldric)); + } + + @Test + void anOldRunLeavesFlagsEarnedAfterAStaffResetAlone() { + CharacterRun first = admit(null, null); + players.getPlayer(player, false).resetEvent(EVENT); + players.setEventOneTime(EVENT, player, aldric, true); + when(tracker.character(owner)).thenReturn(Optional.of(mira)); + + assertFalse(run(first).stillForItsCharacter()); + + assertTrue(players.getEventOneTime(EVENT, player, aldric)); + } + + @Test + void resettingAnotherEventStillLetsTheRunUndo() { + CharacterRun first = admit(null, null); + players.getPlayer(player, false).resetEvent("codex_afk"); + when(tracker.character(owner)).thenReturn(Optional.of(mira)); + + assertFalse(run(first).stillForItsCharacter()); + + assertFalse(players.getEventOneTime(EVENT, player, aldric)); + } + + @Test + void resettingEverythingStopsTheUndo() { + CharacterRun first = admit(null, null); + players.getPlayer(player, false).resetAll(); + players.setEventOneTime(EVENT, player, aldric, true); + when(tracker.character(owner)).thenReturn(Optional.of(mira)); + + assertFalse(run(first).stillForItsCharacter()); + + assertTrue(players.getEventOneTime(EVENT, player, aldric)); + } + + @Test + void unloadedDataIsNotRecreatedByAnUndo() { + CharacterRun record = admit(null, null); + players.removePlayer(players.getPlayer(player, false)); + when(tracker.character(owner)).thenReturn(Optional.empty()); + + assertFalse(run(record).stillForItsCharacter()); + + assertNull(players.getPlayer(player, false)); + } + + @Test + void aRunThatMarkedNothingUndoesNothing() { + players.setEventOneTime(EVENT, player, aldric, true); + when(tracker.character(owner)).thenReturn(Optional.of(mira)); + + assertFalse(run(new CharacterRun(aldric)).stillForItsCharacter()); + + assertTrue(players.getEventOneTime(EVENT, player, aldric)); + } + + @Test + void accountRunsAreNeverDropped() { + when(tracker.character(owner)).thenReturn(Optional.empty()); + + assertTrue(run(null).stillForItsCharacter()); + } + + /** Marks as EventsManager does on admission: one-time always, the cooldown when given. */ + private CharacterRun admit(Long previousCooldown, Long setCooldown) { + CharacterRun record = new CharacterRun(aldric); + players.setEventOneTime(EVENT, player, aldric, true); + PlayerData data = players.getPlayer(player, false); + record.markedOneTime(data.getResetEpoch(EVENT)); + if (setCooldown != null) { + players.setEventCooldown(EVENT, player, aldric, setCooldown); + record.markedCooldown(previousCooldown, setCooldown, data.getResetEpoch(EVENT)); + } + return record; + } + + private ExecutedEvent run(CharacterRun record) { + ExecutedEvent run = new ExecutedEvent(player, new ArrayList<>(), poi100, "default", null, null, plugin, null); + run.bindToCharacter(record); + return run; + } +} diff --git a/src/test/java/net/tfminecraft/conditionalevents/model/player/PlayerDataTest.java b/src/test/java/net/tfminecraft/conditionalevents/model/player/PlayerDataTest.java new file mode 100644 index 0000000..184d8a2 --- /dev/null +++ b/src/test/java/net/tfminecraft/conditionalevents/model/player/PlayerDataTest.java @@ -0,0 +1,75 @@ +package net.tfminecraft.conditionalevents.model.player; + +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.util.UUID; + +import org.junit.jupiter.api.Test; + +class PlayerDataTest { + private final UUID character = UUID.randomUUID(); + private final UUID other = UUID.randomUUID(); + + @Test + void accountAndCharacterDataAreSeparate() { + PlayerData data = new PlayerData(UUID.randomUUID(), "Steve"); + data.setOneTime("codex_die", true); + data.setCooldown(character, "meditate", 100); + + assertTrue(data.isEventOneTime("codex_die")); + assertFalse(data.isEventOneTime(character, "codex_die")); + assertEquals(100, data.getCooldown(character, "meditate")); + assertEquals(0, data.getCooldown("meditate")); + assertEquals(0, data.getCooldown(other, "meditate")); + } + + @Test + void resetsCoverEveryCharacter() { + PlayerData data = new PlayerData(UUID.randomUUID(), "Steve"); + data.setOneTime("codex_die", true); + data.setOneTime(character, "codex_die", true); + data.setOneTime(character, "codex_tf", true); + + data.resetEvent("codex_die"); + assertFalse(data.isEventOneTime("codex_die")); + assertFalse(data.isEventOneTime(character, "codex_die")); + assertTrue(data.isEventOneTime(character, "codex_tf")); + + data.resetAll(); + assertFalse(data.isEventOneTime(character, "codex_tf")); + assertTrue(data.snapshot().characters().isEmpty()); + } + + @Test + void aChangeDuringASaveKeepsTheDataModified() { + PlayerData data = new PlayerData(UUID.randomUUID(), "Steve"); + data.setOneTime(character, "research_bronze", true); + PlayerData.Snapshot saving = data.snapshot(); + + data.setOneTime(character, "research_iron", true); + data.markSaved(saving.revision()); + + assertTrue(data.isModified()); + assertFalse(saving.characters().get(character).stream().anyMatch(e -> e.getName().equals("research_iron"))); + data.markSaved(data.snapshot().revision()); + assertFalse(data.isModified()); + } + + @Test + void snapshotsAreCopies() { + PlayerData data = new PlayerData(UUID.randomUUID(), "Steve"); + data.setCooldown("global_booster", 900); + PlayerData.Snapshot snapshot = data.snapshot(); + + data.setCooldown("global_booster", 1000); + + assertEquals(900, snapshot.account().get(0).getCooldown()); + assertEquals("Steve", snapshot.name()); + data.setModified(false); + assertFalse(data.isModified()); + data.setModified(true); + assertTrue(data.isModified()); + } +}