From 37719e856e307eefa55282a829b09fba6818215e Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:41:32 +0000 Subject: [PATCH 1/7] Add per-character event data with data_scope: character An event can now set data_scope: character to keep its one_time flag and cooldown per roleplay character instead of per account. Such events run only while a character is in play, tracked from TLibs session events so async events can read it. Each account's existing data for those events moves to its RPCharacters heir when a session starts. Events without the setting behave as before. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 +- pom.xml | 92 +++++++++++ .../conditionalevents/ConditionalEvents.java | 14 +- .../character/CharacterTracker.java | 87 ++++++++++ .../conditionalevents/character/Heirs.java | 45 +++++ .../configs/ConfigsManager.java | 9 + .../configs/PlayersConfigsManager.java | 119 ++++++++------ .../managers/EventsManager.java | 5 + .../managers/PlayerManager.java | 42 ++++- .../conditionalevents/model/CEEvent.java | 10 ++ .../model/player/PlayerData.java | 89 +++++++++- src/main/resources/plugin.yml | 3 +- .../character/CharacterTrackerTest.java | 154 ++++++++++++++++++ .../character/HeirsTest.java | 79 +++++++++ .../configs/PlayersConfigsManagerTest.java | 92 +++++++++++ .../managers/PlayerManagerScopeTest.java | 79 +++++++++ .../model/player/PlayerDataTest.java | 75 +++++++++ 17 files changed, 936 insertions(+), 68 deletions(-) create mode 100644 src/main/java/net/tfminecraft/conditionalevents/character/CharacterTracker.java create mode 100644 src/main/java/net/tfminecraft/conditionalevents/character/Heirs.java create mode 100644 src/test/java/net/tfminecraft/conditionalevents/character/CharacterTrackerTest.java create mode 100644 src/test/java/net/tfminecraft/conditionalevents/character/HeirsTest.java create mode 100644 src/test/java/net/tfminecraft/conditionalevents/configs/PlayersConfigsManagerTest.java create mode 100644 src/test/java/net/tfminecraft/conditionalevents/managers/PlayerManagerScopeTest.java create mode 100644 src/test/java/net/tfminecraft/conditionalevents/model/player/PlayerDataTest.java diff --git a/README.md b/README.md index aa1aceb..6145224 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,11 @@ 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. An event with `data_scope: character` keeps its once-only and +cooldown data per roleplay character, and runs only while a character is in play; +other events keep it per account. 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 +21,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..d36ea70 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/ConditionalEvents.java +++ b/src/main/java/net/tfminecraft/conditionalevents/ConditionalEvents.java @@ -1,6 +1,7 @@ package net.tfminecraft.conditionalevents; - +import net.tfminecraft.conditionalevents.character.CharacterTracker; +import net.tfminecraft.conditionalevents.model.CEEvent; import net.tfminecraft.conditionalevents.api.ConditionalEventsAPI; import net.tfminecraft.conditionalevents.api.ExpansionCE; import net.tfminecraft.conditionalevents.configs.ConfigsManager; @@ -35,6 +36,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 +64,10 @@ public void onEnable(){ this.interruptEventManager = new InterruptEventManager(this); this.configsManager = new ConfigsManager(this); this.configsManager.configure(); + this.characterTracker = new CharacterTracker(playerManager, name -> { + CEEvent event = eventsManager.getEvent(name); + return event != null && event.isCharacterScoped(); + }, getLogger()); registerEvents(); registerCommands(); @@ -74,6 +80,7 @@ public void onEnable(){ + characterTracker.start(); reloadPlayerDataSaveTask(); ConditionalEventsAPI api = new ConditionalEventsAPI(this); @@ -97,6 +104,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 +231,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..3a16e97 --- /dev/null +++ b/src/main/java/net/tfminecraft/conditionalevents/character/CharacterTracker.java @@ -0,0 +1,87 @@ +package net.tfminecraft.conditionalevents.character; + +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.Logger; + +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.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; +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. When a session starts, the account's data for per-character events moves + * to the account's heir. + */ +public final class CharacterTracker implements Listener { + private final Map current = new ConcurrentHashMap<>(); + private final PlayerManager players; + private final Predicate characterScoped; + private final Logger logger; + + public CharacterTracker(PlayerManager players, Predicate characterScoped, Logger logger) { + this.players = players; + this.characterScoped = characterScoped; + this.logger = logger; + } + + /** 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) { + migrate(ref.ownerId()); + current.put(ref.ownerId(), ref.characterId()); + } + + /** Gives the account's data for per-character events to its heir. Main thread only. */ + void migrate(UUID owner) { + PlayerData data = players.getPlayerByUUID(owner); + if (data == null) { + return; + } + Heirs.of(owner).ifPresent(heir -> { + int moved = data.moveToCharacter(heir, characterScoped); + if (moved > 0) { + logger.info("Moved " + moved + " per-character event records of account " + owner + " to character " + heir); + } + }); + } +} diff --git a/src/main/java/net/tfminecraft/conditionalevents/character/Heirs.java b/src/main/java/net/tfminecraft/conditionalevents/character/Heirs.java new file mode 100644 index 0000000..c7c2a0c --- /dev/null +++ b/src/main/java/net/tfminecraft/conditionalevents/character/Heirs.java @@ -0,0 +1,45 @@ +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; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.Characters; + +/** Which character inherits an account's event data from before an event was per character. Main thread only. */ +final class Heirs { + private static final Comparator MOST_PLAYED = Comparator + .comparingLong(CharacterInfo::playtimeSeconds) + .thenComparing(info -> info.lastPlayedAt() == null ? Instant.MIN : info.lastPlayedAt()); + + private Heirs() { + } + + /** Empty until RPCharacters knows its heirs, and for accounts with no character yet. */ + static Optional of(UUID owner) { + if (!Characters.legacyHeirsReady()) { + return Optional.empty(); + } + return choose(Characters.legacyHeir(owner), Characters.characters(owner)); + } + + /** + * The reviewed heir RPCharacters gives money and bodies to. Accounts that joined after that review + * have none, so their most-played living character inherits, else their most-played character. + */ + static Optional choose(Optional reviewed, Optional> characters) { + if (reviewed.isPresent()) { + return Optional.of(reviewed.get().characterId()); + } + List all = characters.orElse(List.of()); + Optional heir = all.stream().filter(CharacterInfo::alive).max(MOST_PLAYED); + if (heir.isEmpty()) { + heir = all.stream().max(MOST_PLAYED); + } + return heir.map(info -> info.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..547abb0 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 = false; 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 = "character".equalsIgnoreCase(scope); + if(!characterScoped && !"account".equalsIgnoreCase(scope)){ + plugin.getLogger().warning("Event "+key+" has data_scope "+scope+"; use account or character. Using account."); + } + } 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..0140797 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,20 +61,53 @@ 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.getCharacterEventData().put(UUID.fromString(key),readEvents(characters.getConfigurationSection(key+".events"))); + }catch(IllegalArgumentException notCharacter){ + // Not a character ID. + } + } + } + return playerData; + } - players.set("name", playerName); + 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; + } + + static void write(PlayerData player, ConfigurationSection players){ + players.set("name", player.getName()); players.set("events", null); + players.set("characters", null); + writeEvents(players, "events", player.getEventData()); + for(Map.Entry> character : player.getCharacterEventData().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()); } + } + public void saveConfig(PlayerData player){ + CommonConfig playerConfig = getConfigFile(player.getUuid().toString()+".yml",true); + write(player, playerConfig.getConfig()); playerConfig.saveConfig(); } @@ -122,17 +126,30 @@ public void saveConfigs() { 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..4d491fd 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/managers/EventsManager.java +++ b/src/main/java/net/tfminecraft/conditionalevents/managers/EventsManager.java @@ -61,6 +61,11 @@ public void checkSingularEvent(ConditionEvent conditionEvent,CEEvent event){ return; } + // Per-character events wait until a character is in play. + if(player != null && !plugin.getPlayerManager().canRun(event,player)){ + return; + } + //Ignore if cancelled if(event.isIgnoreIfCancelled()){ Event minecraftEvent = conditionEvent.getMinecraftEvent(); diff --git a/src/main/java/net/tfminecraft/conditionalevents/managers/PlayerManager.java b/src/main/java/net/tfminecraft/conditionalevents/managers/PlayerManager.java index 36a0e98..c37f0ce 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,33 +76,62 @@ 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.isCharacterScoped()){ + return Scope.ACCOUNT; + } + UUID character = plugin.getCharacterTracker().character(player.getUniqueId()).orElse(null); + return new Scope(character != null, character); + } + + /** Per-character events only run while a character is in play. */ + public boolean canRun(CEEvent event, Player player){ + return !event.isCharacterScoped() || plugin.getCharacterTracker().character(player.getUniqueId()).isPresent(); + } + public void setEventCooldown(String eventName,Player player){ + Scope scope = scope(eventName,player); + if(!scope.available()){ + return; + } PlayerData p = getPlayer(player,true); - p.setCooldown(eventName,System.currentTimeMillis()); + p.setCooldown(scope.character(),eventName,System.currentTimeMillis()); p.setModified(true); } public long getEventCooldown(String eventName,Player player){ + Scope scope = scope(eventName,player); PlayerData p = getPlayer(player,false); - if(p == null){ + if(p == null || !scope.available()){ return 0; }else{ - return p.getCooldown(eventName); + return p.getCooldown(scope.character(),eventName); } } public void setEventOneTime(String eventName,Player player){ + Scope scope = scope(eventName,player); + if(!scope.available()){ + return; + } PlayerData p = getPlayer(player,true); - p.setOneTime(eventName,true); + p.setOneTime(scope.character(),eventName,true); p.setModified(true); } public boolean getEventOneTime(String eventName,Player player){ + Scope scope = scope(eventName,player); PlayerData p = getPlayer(player,false); - if(p == null){ + if(p == null || !scope.available()){ return false; }else{ - return p.isEventOneTime(eventName); + return p.isEventOneTime(scope.character(),eventName); } } diff --git a/src/main/java/net/tfminecraft/conditionalevents/model/CEEvent.java b/src/main/java/net/tfminecraft/conditionalevents/model/CEEvent.java index b8ca79b..90cd7fd 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; private String ignoreWithPermission; @@ -73,6 +74,15 @@ public boolean isOneTime() { return oneTime; } + /** Whether one-time and cooldown data belong to the played character ({@code data_scope: character}). */ + public boolean isCharacterScoped() { + return characterScoped; + } + + 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/player/PlayerData.java b/src/main/java/net/tfminecraft/conditionalevents/model/player/PlayerData.java index b69d5da..c9b12cd 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,23 @@ package net.tfminecraft.conditionalevents.model.player; import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Predicate; +/** + * 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. + */ public class PlayerData { private UUID uuid; private String name; private boolean modified; private ArrayList eventData; + private final Map> characterEventData = new ConcurrentHashMap<>(); public PlayerData(UUID uuid, String name) { this.uuid = uuid; @@ -40,8 +50,25 @@ public void setEventData(ArrayList eventData) { this.eventData = eventData; } + public Map> getCharacterEventData() { + return characterEventData; + } + + /** The account's or a character's list, created for a character on first write. */ + private ArrayList writable(UUID character) { + return character == null ? eventData : characterEventData.computeIfAbsent(character, ignored -> new ArrayList<>()); + } + public EventData getEventData(String eventName){ - for(EventData e : eventData){ + return getEventData(null, eventName); + } + + public EventData getEventData(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,28 +76,39 @@ public EventData getEventData(String eventName){ return null; } + /** Resets the event for the account and every character. */ public void resetEvent(String eventName){ eventData.removeIf(c -> c.getName().equals(eventName)); + characterEventData.values().forEach(list -> list.removeIf(c -> c.getName().equals(eventName))); modified = true; } public void resetAll(){ eventData.clear(); + characterEventData.clear(); modified = true; } public void setCooldown(String eventName,long currentMillis){ - EventData e = getEventData(eventName); + setCooldown(null, eventName, currentMillis); + } + + public void setCooldown(UUID character, String eventName, long currentMillis){ + EventData e = getEventData(character, eventName); if(e == null){ e = new EventData(eventName,currentMillis,false); - eventData.add(e); + writable(character).add(e); }else{ e.setCooldown(currentMillis); } } public long getCooldown(String eventName){ - EventData e = getEventData(eventName); + return getCooldown(null, eventName); + } + + public long getCooldown(UUID character, String eventName){ + EventData e = getEventData(character, eventName); if(e == null){ return 0; }else{ @@ -79,17 +117,25 @@ public long getCooldown(String eventName){ } public void setOneTime(String eventName,boolean oneTime){ - EventData e = getEventData(eventName); + setOneTime(null, eventName, oneTime); + } + + public void setOneTime(UUID character, String eventName, boolean oneTime){ + EventData e = getEventData(character, eventName); if(e == null){ e = new EventData(eventName,0,oneTime); - eventData.add(e); + writable(character).add(e); }else{ e.setOneTime(oneTime); } } public boolean isEventOneTime(String eventName){ - EventData e = getEventData(eventName); + return isEventOneTime(null, eventName); + } + + public boolean isEventOneTime(UUID character, String eventName){ + EventData e = getEventData(character, eventName); if(e == null){ return false; }else{ @@ -97,6 +143,35 @@ public boolean isEventOneTime(String eventName){ } } + /** + * Moves the account's data for matching events to a character, merging with what the character + * already has: an event done once stays done, and the later cooldown start wins. + * @return how many events moved + */ + public int moveToCharacter(UUID character, Predicate events){ + int moved = 0; + Iterator account = eventData.iterator(); + while(account.hasNext()){ + EventData e = account.next(); + if(!events.test(e.getName())){ + continue; + } + EventData own = getEventData(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++; + } + if(moved > 0){ + modified = true; + } + return moved; + } + public boolean isModified() { return modified; } diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 4645221..1237b04 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -2,7 +2,8 @@ main: net.tfminecraft.conditionalevents.ConditionalEvents version: ${project.version} name: ConditionalEvents api-version: '1.21.10' -softdepend: [PlaceholderAPI,Citizens,WorldGuardEvents,WorldGuard,ProtocolLib,DiscordSRV,ItemsAdder,PacketEvents] +depend: [TLibs] +softdepend: [RPCharacters,PlaceholderAPI,Citizens,WorldGuardEvents,WorldGuard,ProtocolLib,DiscordSRV,ItemsAdder,PacketEvents] author: Ajneb97 commands: 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..4aaa24b --- /dev/null +++ b/src/test/java/net/tfminecraft/conditionalevents/character/CharacterTrackerTest.java @@ -0,0 +1,154 @@ +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 static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +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.conditionalevents.ConditionalEvents; +import net.tfminecraft.conditionalevents.managers.PlayerManager; +import net.tfminecraft.conditionalevents.model.player.PlayerData; +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 heir = UUID.randomUUID(); + private final UUID alt = UUID.randomUUID(); + private final List logs = new ArrayList<>(); + private MockedStatic characters; + private PlayerManager players; + private CharacterTracker tracker; + private Player player; + + @BeforeEach + void setUp() { + characters = mockStatic(Characters.class); + characters.when(Characters::legacyHeirsReady).thenReturn(true); + characters.when(() -> Characters.legacyHeir(owner)).thenReturn(Optional.of(new CharacterRef(owner, heir))); + 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, logger); + 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(heir)); + 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 theHeirInheritsTheAccountsPerCharacterEvents() { + PlayerData data = new PlayerData(owner, "Steve"); + data.setOneTime("research_bronze", true); + data.setCooldown("global_booster", 900); + players.addPlayer(data); + + tracker.onStart(start(alt)); + + assertTrue(data.isEventOneTime(heir, "research_bronze")); + assertFalse(data.isEventOneTime(alt, "research_bronze")); + assertEquals(900, data.getCooldown("global_booster")); + assertTrue(logs.stream().anyMatch(record -> record.getMessage().contains("Moved 1 per-character event records"))); + } + + @Test + void nothingMovesWithoutDataAHeirOrAMatchingEvent() { + tracker.onStart(start(alt)); + assertTrue(logs.isEmpty()); + + PlayerData data = new PlayerData(owner, "Steve"); + data.setCooldown("global_booster", 900); + players.addPlayer(data); + tracker.onStart(start(alt)); + assertTrue(logs.isEmpty()); + + data.setOneTime("research_bronze", true); + characters.when(Characters::legacyHeirsReady).thenReturn(false); + tracker.onStart(start(alt)); + assertTrue(data.isEventOneTime("research_bronze")); + } + + @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/character/HeirsTest.java b/src/test/java/net/tfminecraft/conditionalevents/character/HeirsTest.java new file mode 100644 index 0000000..ef4ed16 --- /dev/null +++ b/src/test/java/net/tfminecraft/conditionalevents/character/HeirsTest.java @@ -0,0 +1,79 @@ +package net.tfminecraft.conditionalevents.character; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mockStatic; + +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 org.mockito.MockedStatic; + +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterStatus; +import net.tfminecraft.tlibs.character.Characters; + +class HeirsTest { + private final UUID owner = UUID.randomUUID(); + + @Test + void reviewedHeirWins() { + CharacterInfo played = info(CharacterStatus.ALIVE, 9000, null); + UUID reviewed = UUID.randomUUID(); + + assertEquals(Optional.of(reviewed), + Heirs.choose(Optional.of(new CharacterRef(owner, reviewed)), Optional.of(List.of(played)))); + } + + @Test + void otherwiseTheMostPlayedLivingCharacter() { + CharacterInfo dead = info(CharacterStatus.DEAD, 9000, null); + CharacterInfo short1 = info(CharacterStatus.ALIVE, 100, Instant.parse("2026-10-01T00:00:00Z")); + CharacterInfo long1 = info(CharacterStatus.ALIVE, 500, null); + CharacterInfo long2 = info(CharacterStatus.ALIVE, 500, Instant.parse("2026-10-02T00:00:00Z")); + + assertEquals(Optional.of(id(long2)), + Heirs.choose(Optional.empty(), Optional.of(List.of(dead, short1, long1, long2)))); + } + + @Test + void anAllDeadAccountFallsBackToItsMostPlayed() { + CharacterInfo less = info(CharacterStatus.DEAD, 10, null); + CharacterInfo more = info(CharacterStatus.MISSING, 20, null); + + assertEquals(Optional.of(id(more)), Heirs.choose(Optional.empty(), Optional.of(List.of(less, more)))); + } + + @Test + void noCharacterMeansNoHeirYet() { + assertEquals(Optional.empty(), Heirs.choose(Optional.empty(), Optional.of(List.of()))); + assertEquals(Optional.empty(), Heirs.choose(Optional.empty(), Optional.empty())); + } + + @Test + void waitsUntilRpCharactersKnowsItsHeirs() { + UUID reviewed = UUID.randomUUID(); + try (MockedStatic characters = mockStatic(Characters.class)) { + characters.when(Characters::legacyHeirsReady).thenReturn(false); + assertEquals(Optional.empty(), Heirs.of(owner)); + + characters.when(Characters::legacyHeirsReady).thenReturn(true); + characters.when(() -> Characters.legacyHeir(owner)).thenReturn(Optional.of(new CharacterRef(owner, reviewed))); + characters.when(() -> Characters.characters(owner)).thenReturn(Optional.of(List.of())); + assertEquals(Optional.of(reviewed), Heirs.of(owner)); + } + } + + 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 new file mode 100644 index 0000000..216cb64 --- /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, 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.getCharacterEventData().size()); + assertTrue(loaded.getCharacterEventData().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..1122832 --- /dev/null +++ b/src/test/java/net/tfminecraft/conditionalevents/managers/PlayerManagerScopeTest.java @@ -0,0 +1,79 @@ +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.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; + private CEEvent research; + private CEEvent booster; + + @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); + research = new CEEvent("research_bronze"); + research.setCharacterScoped(true); + booster = new CEEvent("global_booster"); + 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 perCharacterEventsWaitForACharacter() { + assertFalse(players.canRun(research, player)); + assertTrue(players.canRun(booster, player)); + + players.setEventOneTime("research_bronze", player); + players.setEventCooldown("research_bronze", player); + assertFalse(players.getEventOneTime("research_bronze", player)); + assertEquals(0, players.getEventCooldown("research_bronze", player)); + assertEquals(null, players.getPlayer(player, false)); + } + + @Test + void perCharacterEventsUseThePlayedCharactersData() { + when(tracker.character(owner)).thenReturn(Optional.of(character)); + + assertTrue(players.canRun(research, player)); + players.setEventOneTime("research_bronze", player); + players.setEventCooldown("research_bronze", player); + players.setEventOneTime("global_booster", player); + + PlayerData data = players.getPlayer(player, false); + 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)); + } +} 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..2e95bfb --- /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.Set; +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 movingGivesTheHeirOnlyTheChosenEventsAndMerges() { + PlayerData data = new PlayerData(UUID.randomUUID(), "Steve"); + data.setOneTime("research_bronze", true); + data.setCooldown("research_bronze", 50); + data.setOneTime("codex_die", true); + data.setCooldown("global_booster", 900); + data.setCooldown(character, "codex_die", 70); + + int moved = data.moveToCharacter(character, Set.of("research_bronze", "codex_die")::contains); + + assertEquals(2, moved); + assertTrue(data.isModified()); + assertTrue(data.isEventOneTime(character, "research_bronze")); + assertEquals(50, data.getCooldown(character, "research_bronze")); + assertTrue(data.isEventOneTime(character, "codex_die")); + assertEquals(70, data.getCooldown(character, "codex_die")); + assertFalse(data.isEventOneTime("research_bronze")); + assertEquals(900, data.getCooldown("global_booster")); + } + + @Test + void movingNothingLeavesTheAccountUnmodified() { + PlayerData data = new PlayerData(UUID.randomUUID(), "Steve"); + data.setCooldown("global_booster", 900); + + assertEquals(0, data.moveToCharacter(character, name -> false)); + assertFalse(data.isModified()); + } + + @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.getCharacterEventData().isEmpty()); + } +} From 573df091c59a778b58c6b84cba08f9a0ea3e3798 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:50:42 +0000 Subject: [PATCH 2/7] Fall back when the reviewed heir was deleted; avoid a load cycle Match Codex: a reviewed heir missing from a known roster falls through to the most-played rule. Drop RPCharacters from softdepend, since RPCharacters loads after SimpleFactions, which loads after ConditionalEvents. Co-Authored-By: Claude Opus 5.5 --- .../conditionalevents/character/Heirs.java | 9 ++++++--- src/main/resources/plugin.yml | 2 +- .../conditionalevents/character/HeirsTest.java | 18 ++++++++++++++---- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/main/java/net/tfminecraft/conditionalevents/character/Heirs.java b/src/main/java/net/tfminecraft/conditionalevents/character/Heirs.java index c7c2a0c..ae0b929 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/character/Heirs.java +++ b/src/main/java/net/tfminecraft/conditionalevents/character/Heirs.java @@ -29,13 +29,16 @@ static Optional of(UUID owner) { /** * The reviewed heir RPCharacters gives money and bodies to. Accounts that joined after that review - * have none, so their most-played living character inherits, else their most-played character. + * have none, and a reviewed heir can since have been deleted; then the most-played living character + * inherits, else the most-played character. */ static Optional choose(Optional reviewed, Optional> characters) { - if (reviewed.isPresent()) { + List all = characters.orElse(List.of()); + // A reviewed heir deleted since the review falls through to the rule below. + if (reviewed.isPresent() && (characters.isEmpty() + || all.stream().anyMatch(info -> info.ref().characterId().equals(reviewed.get().characterId())))) { return Optional.of(reviewed.get().characterId()); } - List all = characters.orElse(List.of()); Optional heir = all.stream().filter(CharacterInfo::alive).max(MOST_PLAYED); if (heir.isEmpty()) { heir = all.stream().max(MOST_PLAYED); diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 1237b04..4dc58d8 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -3,7 +3,7 @@ version: ${project.version} name: ConditionalEvents api-version: '1.21.10' depend: [TLibs] -softdepend: [RPCharacters,PlaceholderAPI,Citizens,WorldGuardEvents,WorldGuard,ProtocolLib,DiscordSRV,ItemsAdder,PacketEvents] +softdepend: [PlaceholderAPI,Citizens,WorldGuardEvents,WorldGuard,ProtocolLib,DiscordSRV,ItemsAdder,PacketEvents] author: Ajneb97 commands: diff --git a/src/test/java/net/tfminecraft/conditionalevents/character/HeirsTest.java b/src/test/java/net/tfminecraft/conditionalevents/character/HeirsTest.java index ef4ed16..67995f5 100644 --- a/src/test/java/net/tfminecraft/conditionalevents/character/HeirsTest.java +++ b/src/test/java/net/tfminecraft/conditionalevents/character/HeirsTest.java @@ -23,10 +23,20 @@ class HeirsTest { @Test void reviewedHeirWins() { CharacterInfo played = info(CharacterStatus.ALIVE, 9000, null); - UUID reviewed = UUID.randomUUID(); + CharacterInfo reviewed = info(CharacterStatus.ALIVE, 10, null); + + assertEquals(Optional.of(id(reviewed)), + Heirs.choose(Optional.of(reviewed.ref()), Optional.of(List.of(played, reviewed)))); + } + + @Test + void aReviewedHeirDeletedSinceFallsThroughToTheRoster() { + CharacterInfo left = info(CharacterStatus.ALIVE, 10, null); + UUID deleted = UUID.randomUUID(); - assertEquals(Optional.of(reviewed), - Heirs.choose(Optional.of(new CharacterRef(owner, reviewed)), Optional.of(List.of(played)))); + assertEquals(Optional.of(id(left)), + Heirs.choose(Optional.of(new CharacterRef(owner, deleted)), Optional.of(List.of(left)))); + assertEquals(Optional.of(deleted), Heirs.choose(Optional.of(new CharacterRef(owner, deleted)), Optional.empty())); } @Test @@ -63,7 +73,7 @@ void waitsUntilRpCharactersKnowsItsHeirs() { characters.when(Characters::legacyHeirsReady).thenReturn(true); characters.when(() -> Characters.legacyHeir(owner)).thenReturn(Optional.of(new CharacterRef(owner, reviewed))); - characters.when(() -> Characters.characters(owner)).thenReturn(Optional.of(List.of())); + characters.when(() -> Characters.characters(owner)).thenReturn(Optional.empty()); assertEquals(Optional.of(reviewed), Heirs.of(owner)); } } From f01e728ba466a409c35034211d8509e5b4491928 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:58:03 +0000 Subject: [PATCH 3/7] Start per-character events fresh for every character Old per-account data doesn't say which character ran an event, so no character inherits it. Per-character events read only character data; account-level entries for them stay in the file, unused. This removes the heir lookup and the move at session start. Co-Authored-By: Claude Opus 5.5 --- .../conditionalevents/ConditionalEvents.java | 6 +- .../character/CharacterTracker.java | 30 +------ .../conditionalevents/character/Heirs.java | 48 ---------- .../model/player/PlayerData.java | 31 ------- .../character/CharacterTrackerTest.java | 68 +------------- .../character/HeirsTest.java | 89 ------------------- .../model/player/PlayerDataTest.java | 31 ------- 7 files changed, 5 insertions(+), 298 deletions(-) delete mode 100644 src/main/java/net/tfminecraft/conditionalevents/character/Heirs.java delete mode 100644 src/test/java/net/tfminecraft/conditionalevents/character/HeirsTest.java diff --git a/src/main/java/net/tfminecraft/conditionalevents/ConditionalEvents.java b/src/main/java/net/tfminecraft/conditionalevents/ConditionalEvents.java index d36ea70..7a466b0 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/ConditionalEvents.java +++ b/src/main/java/net/tfminecraft/conditionalevents/ConditionalEvents.java @@ -1,7 +1,6 @@ package net.tfminecraft.conditionalevents; import net.tfminecraft.conditionalevents.character.CharacterTracker; -import net.tfminecraft.conditionalevents.model.CEEvent; import net.tfminecraft.conditionalevents.api.ConditionalEventsAPI; import net.tfminecraft.conditionalevents.api.ExpansionCE; import net.tfminecraft.conditionalevents.configs.ConfigsManager; @@ -64,10 +63,7 @@ public void onEnable(){ this.interruptEventManager = new InterruptEventManager(this); this.configsManager = new ConfigsManager(this); this.configsManager.configure(); - this.characterTracker = new CharacterTracker(playerManager, name -> { - CEEvent event = eventsManager.getEvent(name); - return event != null && event.isCharacterScoped(); - }, getLogger()); + this.characterTracker = new CharacterTracker(); 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 3a16e97..af79f7d 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/character/CharacterTracker.java +++ b/src/main/java/net/tfminecraft/conditionalevents/character/CharacterTracker.java @@ -4,8 +4,6 @@ import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Predicate; -import java.util.logging.Logger; import org.bukkit.Bukkit; import org.bukkit.entity.Player; @@ -14,8 +12,6 @@ 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; @@ -23,20 +19,10 @@ /** * Which character each player is playing, readable from any thread, since events such as chat run - * off the main thread. When a session starts, the account's data for per-character events moves - * to the account's heir. + * off the main thread. */ public final class CharacterTracker implements Listener { private final Map current = new ConcurrentHashMap<>(); - private final PlayerManager players; - private final Predicate characterScoped; - private final Logger logger; - - public CharacterTracker(PlayerManager players, Predicate characterScoped, Logger logger) { - this.players = players; - this.characterScoped = characterScoped; - this.logger = logger; - } /** The character the player is playing, or empty between characters. */ public Optional character(UUID account) { @@ -67,21 +53,7 @@ public void onQuit(PlayerQuitEvent event) { } private void begin(CharacterRef ref) { - migrate(ref.ownerId()); current.put(ref.ownerId(), ref.characterId()); } - /** Gives the account's data for per-character events to its heir. Main thread only. */ - void migrate(UUID owner) { - PlayerData data = players.getPlayerByUUID(owner); - if (data == null) { - return; - } - Heirs.of(owner).ifPresent(heir -> { - int moved = data.moveToCharacter(heir, characterScoped); - if (moved > 0) { - logger.info("Moved " + moved + " per-character event records of account " + owner + " to character " + heir); - } - }); - } } diff --git a/src/main/java/net/tfminecraft/conditionalevents/character/Heirs.java b/src/main/java/net/tfminecraft/conditionalevents/character/Heirs.java deleted file mode 100644 index ae0b929..0000000 --- a/src/main/java/net/tfminecraft/conditionalevents/character/Heirs.java +++ /dev/null @@ -1,48 +0,0 @@ -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; -import net.tfminecraft.tlibs.character.CharacterRef; -import net.tfminecraft.tlibs.character.Characters; - -/** Which character inherits an account's event data from before an event was per character. Main thread only. */ -final class Heirs { - private static final Comparator MOST_PLAYED = Comparator - .comparingLong(CharacterInfo::playtimeSeconds) - .thenComparing(info -> info.lastPlayedAt() == null ? Instant.MIN : info.lastPlayedAt()); - - private Heirs() { - } - - /** Empty until RPCharacters knows its heirs, and for accounts with no character yet. */ - static Optional of(UUID owner) { - if (!Characters.legacyHeirsReady()) { - return Optional.empty(); - } - return choose(Characters.legacyHeir(owner), Characters.characters(owner)); - } - - /** - * The reviewed heir RPCharacters gives money and bodies to. Accounts that joined after that review - * have none, and a reviewed heir can since have been deleted; then the most-played living character - * inherits, else the most-played character. - */ - static Optional choose(Optional reviewed, Optional> characters) { - List all = characters.orElse(List.of()); - // A reviewed heir deleted since the review falls through to the rule below. - if (reviewed.isPresent() && (characters.isEmpty() - || all.stream().anyMatch(info -> info.ref().characterId().equals(reviewed.get().characterId())))) { - return Optional.of(reviewed.get().characterId()); - } - Optional heir = all.stream().filter(CharacterInfo::alive).max(MOST_PLAYED); - if (heir.isEmpty()) { - heir = all.stream().max(MOST_PLAYED); - } - return heir.map(info -> info.ref().characterId()); - } -} 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 c9b12cd..3ba3ae0 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/model/player/PlayerData.java +++ b/src/main/java/net/tfminecraft/conditionalevents/model/player/PlayerData.java @@ -1,12 +1,10 @@ package net.tfminecraft.conditionalevents.model.player; import java.util.ArrayList; -import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Predicate; /** * An account's event data. Events with {@code data_scope: character} keep theirs per character; @@ -143,35 +141,6 @@ public boolean isEventOneTime(UUID character, String eventName){ } } - /** - * Moves the account's data for matching events to a character, merging with what the character - * already has: an event done once stays done, and the later cooldown start wins. - * @return how many events moved - */ - public int moveToCharacter(UUID character, Predicate events){ - int moved = 0; - Iterator account = eventData.iterator(); - while(account.hasNext()){ - EventData e = account.next(); - if(!events.test(e.getName())){ - continue; - } - EventData own = getEventData(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++; - } - if(moved > 0){ - modified = true; - } - return moved; - } - public boolean isModified() { return modified; } diff --git a/src/test/java/net/tfminecraft/conditionalevents/character/CharacterTrackerTest.java b/src/test/java/net/tfminecraft/conditionalevents/character/CharacterTrackerTest.java index 4aaa24b..bb5bc13 100644 --- a/src/test/java/net/tfminecraft/conditionalevents/character/CharacterTrackerTest.java +++ b/src/test/java/net/tfminecraft/conditionalevents/character/CharacterTrackerTest.java @@ -1,20 +1,14 @@ 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 static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; -import java.util.ArrayList; import java.util.List; import java.util.Optional; -import java.util.Set; import java.util.UUID; -import java.util.logging.Handler; -import java.util.logging.LogRecord; -import java.util.logging.Logger; import org.bukkit.Bukkit; import org.bukkit.entity.Player; @@ -24,9 +18,6 @@ import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; -import net.tfminecraft.conditionalevents.ConditionalEvents; -import net.tfminecraft.conditionalevents.managers.PlayerManager; -import net.tfminecraft.conditionalevents.model.player.PlayerData; import net.tfminecraft.tlibs.character.CharacterRef; import net.tfminecraft.tlibs.character.CharacterSession; import net.tfminecraft.tlibs.character.CharacterSessionEndEvent; @@ -35,37 +26,16 @@ class CharacterTrackerTest { private final UUID owner = UUID.randomUUID(); - private final UUID heir = UUID.randomUUID(); + private final UUID other = UUID.randomUUID(); private final UUID alt = UUID.randomUUID(); - private final List logs = new ArrayList<>(); private MockedStatic characters; - private PlayerManager players; private CharacterTracker tracker; private Player player; @BeforeEach void setUp() { characters = mockStatic(Characters.class); - characters.when(Characters::legacyHeirsReady).thenReturn(true); - characters.when(() -> Characters.legacyHeir(owner)).thenReturn(Optional.of(new CharacterRef(owner, heir))); - 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, logger); + tracker = new CharacterTracker(); player = mock(Player.class); when(player.getUniqueId()).thenReturn(owner); } @@ -82,7 +52,7 @@ void tracksThePlayedCharacterAcrossSwitches() { tracker.onStart(start(alt)); assertEquals(Optional.of(alt), tracker.character(owner)); - tracker.onEnd(end(heir)); + tracker.onEnd(end(other)); assertEquals(Optional.of(alt), tracker.character(owner)); tracker.onEnd(end(alt)); @@ -98,38 +68,6 @@ void quittingForgetsTheCharacter() { assertTrue(tracker.character(owner).isEmpty()); } - @Test - void theHeirInheritsTheAccountsPerCharacterEvents() { - PlayerData data = new PlayerData(owner, "Steve"); - data.setOneTime("research_bronze", true); - data.setCooldown("global_booster", 900); - players.addPlayer(data); - - tracker.onStart(start(alt)); - - assertTrue(data.isEventOneTime(heir, "research_bronze")); - assertFalse(data.isEventOneTime(alt, "research_bronze")); - assertEquals(900, data.getCooldown("global_booster")); - assertTrue(logs.stream().anyMatch(record -> record.getMessage().contains("Moved 1 per-character event records"))); - } - - @Test - void nothingMovesWithoutDataAHeirOrAMatchingEvent() { - tracker.onStart(start(alt)); - assertTrue(logs.isEmpty()); - - PlayerData data = new PlayerData(owner, "Steve"); - data.setCooldown("global_booster", 900); - players.addPlayer(data); - tracker.onStart(start(alt)); - assertTrue(logs.isEmpty()); - - data.setOneTime("research_bronze", true); - characters.when(Characters::legacyHeirsReady).thenReturn(false); - tracker.onStart(start(alt)); - assertTrue(data.isEventOneTime("research_bronze")); - } - @Test void startPicksUpCharactersAlreadyInPlay() { characters.when(() -> Characters.currentSession(player)) diff --git a/src/test/java/net/tfminecraft/conditionalevents/character/HeirsTest.java b/src/test/java/net/tfminecraft/conditionalevents/character/HeirsTest.java deleted file mode 100644 index 67995f5..0000000 --- a/src/test/java/net/tfminecraft/conditionalevents/character/HeirsTest.java +++ /dev/null @@ -1,89 +0,0 @@ -package net.tfminecraft.conditionalevents.character; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.mockStatic; - -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 org.mockito.MockedStatic; - -import net.tfminecraft.tlibs.character.CharacterInfo; -import net.tfminecraft.tlibs.character.CharacterRef; -import net.tfminecraft.tlibs.character.CharacterStatus; -import net.tfminecraft.tlibs.character.Characters; - -class HeirsTest { - private final UUID owner = UUID.randomUUID(); - - @Test - void reviewedHeirWins() { - CharacterInfo played = info(CharacterStatus.ALIVE, 9000, null); - CharacterInfo reviewed = info(CharacterStatus.ALIVE, 10, null); - - assertEquals(Optional.of(id(reviewed)), - Heirs.choose(Optional.of(reviewed.ref()), Optional.of(List.of(played, reviewed)))); - } - - @Test - void aReviewedHeirDeletedSinceFallsThroughToTheRoster() { - CharacterInfo left = info(CharacterStatus.ALIVE, 10, null); - UUID deleted = UUID.randomUUID(); - - assertEquals(Optional.of(id(left)), - Heirs.choose(Optional.of(new CharacterRef(owner, deleted)), Optional.of(List.of(left)))); - assertEquals(Optional.of(deleted), Heirs.choose(Optional.of(new CharacterRef(owner, deleted)), Optional.empty())); - } - - @Test - void otherwiseTheMostPlayedLivingCharacter() { - CharacterInfo dead = info(CharacterStatus.DEAD, 9000, null); - CharacterInfo short1 = info(CharacterStatus.ALIVE, 100, Instant.parse("2026-10-01T00:00:00Z")); - CharacterInfo long1 = info(CharacterStatus.ALIVE, 500, null); - CharacterInfo long2 = info(CharacterStatus.ALIVE, 500, Instant.parse("2026-10-02T00:00:00Z")); - - assertEquals(Optional.of(id(long2)), - Heirs.choose(Optional.empty(), Optional.of(List.of(dead, short1, long1, long2)))); - } - - @Test - void anAllDeadAccountFallsBackToItsMostPlayed() { - CharacterInfo less = info(CharacterStatus.DEAD, 10, null); - CharacterInfo more = info(CharacterStatus.MISSING, 20, null); - - assertEquals(Optional.of(id(more)), Heirs.choose(Optional.empty(), Optional.of(List.of(less, more)))); - } - - @Test - void noCharacterMeansNoHeirYet() { - assertEquals(Optional.empty(), Heirs.choose(Optional.empty(), Optional.of(List.of()))); - assertEquals(Optional.empty(), Heirs.choose(Optional.empty(), Optional.empty())); - } - - @Test - void waitsUntilRpCharactersKnowsItsHeirs() { - UUID reviewed = UUID.randomUUID(); - try (MockedStatic characters = mockStatic(Characters.class)) { - characters.when(Characters::legacyHeirsReady).thenReturn(false); - assertEquals(Optional.empty(), Heirs.of(owner)); - - characters.when(Characters::legacyHeirsReady).thenReturn(true); - characters.when(() -> Characters.legacyHeir(owner)).thenReturn(Optional.of(new CharacterRef(owner, reviewed))); - characters.when(() -> Characters.characters(owner)).thenReturn(Optional.empty()); - assertEquals(Optional.of(reviewed), Heirs.of(owner)); - } - } - - 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/model/player/PlayerDataTest.java b/src/test/java/net/tfminecraft/conditionalevents/model/player/PlayerDataTest.java index 2e95bfb..40658a1 100644 --- a/src/test/java/net/tfminecraft/conditionalevents/model/player/PlayerDataTest.java +++ b/src/test/java/net/tfminecraft/conditionalevents/model/player/PlayerDataTest.java @@ -4,7 +4,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.util.Set; import java.util.UUID; import org.junit.jupiter.api.Test; @@ -26,36 +25,6 @@ void accountAndCharacterDataAreSeparate() { assertEquals(0, data.getCooldown(other, "meditate")); } - @Test - void movingGivesTheHeirOnlyTheChosenEventsAndMerges() { - PlayerData data = new PlayerData(UUID.randomUUID(), "Steve"); - data.setOneTime("research_bronze", true); - data.setCooldown("research_bronze", 50); - data.setOneTime("codex_die", true); - data.setCooldown("global_booster", 900); - data.setCooldown(character, "codex_die", 70); - - int moved = data.moveToCharacter(character, Set.of("research_bronze", "codex_die")::contains); - - assertEquals(2, moved); - assertTrue(data.isModified()); - assertTrue(data.isEventOneTime(character, "research_bronze")); - assertEquals(50, data.getCooldown(character, "research_bronze")); - assertTrue(data.isEventOneTime(character, "codex_die")); - assertEquals(70, data.getCooldown(character, "codex_die")); - assertFalse(data.isEventOneTime("research_bronze")); - assertEquals(900, data.getCooldown("global_booster")); - } - - @Test - void movingNothingLeavesTheAccountUnmodified() { - PlayerData data = new PlayerData(UUID.randomUUID(), "Steve"); - data.setCooldown("global_booster", 900); - - assertEquals(0, data.moveToCharacter(character, name -> false)); - assertFalse(data.isModified()); - } - @Test void resetsCoverEveryCharacter() { PlayerData data = new PlayerData(UUID.randomUUID(), "Steve"); From 3f638c7f210d59ed5302209123f800af790e054d Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:05:31 +0000 Subject: [PATCH 4/7] Address review: bind per-character runs to their character; safe saves - A per-character event fixes its character when it starts and uses it for every check and write. If that character is no longer in play when an action is due (after wait_ticks, or after an async event hands over to the main thread), the rest is dropped and the one-time flag and cooldown it set are undone, so the character can earn it again. - Events without a player never run per-character. - PlayerData is locked for every access; saves write a snapshot, clear the dirty flag only if nothing changed meanwhile, and never write one account's file twice at once. Co-Authored-By: Claude Opus 5.5 --- .../configs/PlayersConfigsManager.java | 22 ++-- .../managers/EventsManager.java | 26 ++-- .../managers/PlayerManager.java | 51 ++++---- .../model/internal/ExecutedEvent.java | 43 +++++++ .../model/player/PlayerData.java | 116 +++++++++++------- .../configs/PlayersConfigsManagerTest.java | 6 +- .../managers/PlayerManagerScopeTest.java | 31 +++-- .../internal/ExecutedEventCharacterTest.java | 97 +++++++++++++++ .../model/player/PlayerDataTest.java | 33 ++++- 9 files changed, 327 insertions(+), 98 deletions(-) create mode 100644 src/test/java/net/tfminecraft/conditionalevents/model/internal/ExecutedEventCharacterTest.java diff --git a/src/main/java/net/tfminecraft/conditionalevents/configs/PlayersConfigsManager.java b/src/main/java/net/tfminecraft/conditionalevents/configs/PlayersConfigsManager.java index 0140797..942d6df 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/configs/PlayersConfigsManager.java +++ b/src/main/java/net/tfminecraft/conditionalevents/configs/PlayersConfigsManager.java @@ -69,7 +69,7 @@ static PlayerData read(UUID uuid, ConfigurationSection config){ if(characters != null){ for(String key : characters.getKeys(false)){ try{ - playerData.getCharacterEventData().put(UUID.fromString(key),readEvents(characters.getConfigurationSection(key+".events"))); + playerData.setCharacterEventData(UUID.fromString(key),readEvents(characters.getConfigurationSection(key+".events"))); }catch(IllegalArgumentException notCharacter){ // Not a character ID. } @@ -88,12 +88,12 @@ private static ArrayList readEvents(ConfigurationSection events){ return eventData; } - static void write(PlayerData player, ConfigurationSection players){ - players.set("name", player.getName()); + 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.getEventData()); - for(Map.Entry> character : player.getCharacterEventData().entrySet()){ + writeEvents(players, "events", player.account()); + for(Map.Entry> character : player.characters().entrySet()){ writeEvents(players, "characters."+character.getKey()+".events", character.getValue()); } } @@ -105,10 +105,15 @@ 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){ - CommonConfig playerConfig = getConfigFile(player.getUuid().toString()+".yml",true); - write(player, playerConfig.getConfig()); - playerConfig.saveConfig(); + 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 @@ -119,7 +124,6 @@ public void saveConfigs() { if(playerData.isModified()){ saveConfig(playerData); } - playerData.setModified(false); } } diff --git a/src/main/java/net/tfminecraft/conditionalevents/managers/EventsManager.java b/src/main/java/net/tfminecraft/conditionalevents/managers/EventsManager.java index 4d491fd..bb1151a 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,9 +62,13 @@ public void checkSingularEvent(ConditionEvent conditionEvent,CEEvent event){ return; } - // Per-character events wait until a character is in play. - if(player != null && !plugin.getPlayerManager().canRun(event,player)){ - return; + // Per-character events need a player playing a character, fixed here for the whole run. + UUID character = null; + if(event.isCharacterScoped()){ + character = player == null ? null : plugin.getPlayerManager().playedCharacter(player); + if(character == null){ + return; + } } //Ignore if cancelled @@ -94,11 +99,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(character,false,null); plugin.getDebugManager().sendActionsMessage(event.getName(),"one_time",player); executedEvent.executeActions(); return; @@ -107,7 +113,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); @@ -115,6 +121,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(character,false,null); plugin.getDebugManager().sendActionsMessage(event.getName(),"cooldown",player); executedEvent.executeActions(); return; @@ -122,6 +129,8 @@ public void checkSingularEvent(ConditionEvent conditionEvent,CEEvent event){ } } + boolean markedOneTime = false; + Long previousCooldown = null; if(player != null){ // Ignore parameters String actionGroupWithoutParameters = executeActionGroup; @@ -133,14 +142,16 @@ 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); + markedOneTime = true; } } //Set Cooldown if(event.getCooldown() != 0 && !bypassCooldown){ if(!event.getPreventCooldownActivationActionGroups().contains(actionGroupWithoutParameters)){ - playerManager.setEventCooldown(event.getName(),player); + previousCooldown = playerManager.getEventCooldown(event.getName(),player,character); + playerManager.setEventCooldown(event.getName(),player,character,System.currentTimeMillis()); } } } @@ -149,6 +160,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(character,markedOneTime,previousCooldown); 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 c37f0ce..fc51bb9 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/managers/PlayerManager.java +++ b/src/main/java/net/tfminecraft/conditionalevents/managers/PlayerManager.java @@ -86,53 +86,56 @@ private Scope scope(String eventName, Player player){ if(event == null || !event.isCharacterScoped()){ return Scope.ACCOUNT; } - UUID character = plugin.getCharacterTracker().character(player.getUniqueId()).orElse(null); + UUID character = playedCharacter(player); return new Scope(character != null, character); } - /** Per-character events only run while a character is in play. */ - public boolean canRun(CEEvent event, Player player){ - return !event.isCharacterScoped() || plugin.getCharacterTracker().character(player.getUniqueId()).isPresent(); + /** 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){ Scope scope = scope(eventName,player); - if(!scope.available()){ - return; + if(scope.available()){ + setEventCooldown(eventName,player,scope.character(),System.currentTimeMillis()); } - PlayerData p = getPlayer(player,true); - p.setCooldown(scope.character(),eventName,System.currentTimeMillis()); - p.setModified(true); + } + + /** {@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 || !scope.available()){ - return 0; - }else{ - return p.getCooldown(scope.character(),eventName); - } + return p == null ? 0 : p.getCooldown(character,eventName); } public void setEventOneTime(String eventName,Player player){ Scope scope = scope(eventName,player); - if(!scope.available()){ - return; + if(scope.available()){ + setEventOneTime(eventName,player,scope.character(),true); } - PlayerData p = getPlayer(player,true); - p.setOneTime(scope.character(),eventName,true); - p.setModified(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 || !scope.available()){ - return false; - }else{ - return p.isEventOneTime(scope.character(),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/internal/ExecutedEvent.java b/src/main/java/net/tfminecraft/conditionalevents/model/internal/ExecutedEvent.java index 30d97d1..05b9113 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; @@ -19,6 +20,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.UUID; // Represent an event that has accomplished all conditions and actions // are being executed. @@ -39,6 +41,12 @@ public class ExecutedEvent { private boolean onWait; private int currentActionPos; + // Per-character runs: the character they started for and what they set, to undo if it leaves. + private UUID character; + private boolean markedOneTime; + private Long previousCooldown; + private boolean abandoned; + public ExecutedEvent(Player player, ArrayList eventVariables, CEEvent event, String actionGroupName , Event minecraftEvent, LivingEntity target, ConditionalEvents plugin, AdditionalEventStorage additionalEventStorage) { this.player = player; @@ -131,12 +139,47 @@ public void executeCancelActions(){ } } + /** + * Ties a per-character run 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 one-time flag and cooldown it set are undone, so the + * character can earn it again. {@code character} null leaves the run unbound. + */ + public void bindToCharacter(UUID character, boolean markedOneTime, Long previousCooldown){ + this.character = character; + this.markedOneTime = markedOneTime; + this.previousCooldown = previousCooldown; + } + + /** Whether the run may carry on; drops it, once, when its character has left. */ + boolean stillForItsCharacter(){ + if(abandoned){ + return false; + } + if(character == null || character.equals(plugin.getPlayerManager().playedCharacter(player))){ + return true; + } + abandoned = true; + PlayerManager playerManager = plugin.getPlayerManager(); + if(markedOneTime){ + playerManager.setEventOneTime(event.getName(),player,character,false); + } + if(previousCooldown != null){ + playerManager.setEventCooldown(event.getName(),player,character,previousCooldown); + } + 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 3ba3ae0..cf3a732 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/model/player/PlayerData.java +++ b/src/main/java/net/tfminecraft/conditionalevents/model/player/PlayerData.java @@ -1,21 +1,26 @@ 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; -import java.util.concurrent.ConcurrentHashMap; /** * 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 ArrayList eventData; - private final Map> characterEventData = new ConcurrentHashMap<>(); + private final Map> characterEventData = new HashMap<>(); + private final Object saveLock = new Object(); public PlayerData(UUID uuid, String name) { this.uuid = uuid; @@ -24,6 +29,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; } @@ -32,36 +41,54 @@ 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 Map> getCharacterEventData() { - return characterEventData; + /** Loading only. */ + public synchronized void setCharacterEventData(UUID character, ArrayList events) { + characterEventData.put(character, events); } - /** The account's or a character's list, created for a character on first write. */ - private ArrayList writable(UUID character) { - return character == null ? eventData : characterEventData.computeIfAbsent(character, ignored -> new ArrayList<>()); + 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); } - public EventData getEventData(String eventName){ - return getEventData(null, eventName); + 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; } - public EventData getEventData(UUID character, String eventName){ + /** 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; @@ -74,78 +101,79 @@ public EventData getEventData(UUID character, String eventName){ return null; } + private void touched() { + revision++; + modified = true; + } + /** Resets the event for the account and every character. */ - public void resetEvent(String eventName){ + public synchronized void resetEvent(String eventName){ eventData.removeIf(c -> c.getName().equals(eventName)); characterEventData.values().forEach(list -> list.removeIf(c -> c.getName().equals(eventName))); - modified = true; + touched(); } - public void resetAll(){ + public synchronized void resetAll(){ eventData.clear(); characterEventData.clear(); - modified = true; + touched(); } public void setCooldown(String eventName,long currentMillis){ setCooldown(null, eventName, currentMillis); } - public void setCooldown(UUID character, String eventName, long currentMillis){ - EventData e = getEventData(character, eventName); + public synchronized void setCooldown(UUID character, String eventName, long currentMillis){ + EventData e = find(character, eventName); if(e == null){ - e = new EventData(eventName,currentMillis,false); - writable(character).add(e); + writable(character).add(new EventData(eventName,currentMillis,false)); }else{ e.setCooldown(currentMillis); } + touched(); } public long getCooldown(String eventName){ return getCooldown(null, eventName); } - public long getCooldown(UUID character, String eventName){ - EventData e = getEventData(character, eventName); - if(e == null){ - return 0; - }else{ - return e.getCooldown(); - } + 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){ setOneTime(null, eventName, oneTime); } - public void setOneTime(UUID character, String eventName, boolean oneTime){ - EventData e = getEventData(character, eventName); + public synchronized void setOneTime(UUID character, String eventName, boolean oneTime){ + EventData e = find(character, eventName); if(e == null){ - e = new EventData(eventName,0,oneTime); - writable(character).add(e); + writable(character).add(new EventData(eventName,0,oneTime)); }else{ e.setOneTime(oneTime); } + touched(); } public boolean isEventOneTime(String eventName){ return isEventOneTime(null, eventName); } - public boolean isEventOneTime(UUID character, String eventName){ - EventData e = getEventData(character, eventName); - if(e == null){ - return false; - }else{ - return e.isOneTime(); - } + 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/test/java/net/tfminecraft/conditionalevents/configs/PlayersConfigsManagerTest.java b/src/test/java/net/tfminecraft/conditionalevents/configs/PlayersConfigsManagerTest.java index 216cb64..ce92743 100644 --- a/src/test/java/net/tfminecraft/conditionalevents/configs/PlayersConfigsManagerTest.java +++ b/src/test/java/net/tfminecraft/conditionalevents/configs/PlayersConfigsManagerTest.java @@ -22,7 +22,7 @@ void characterEventsRoundTripBesideAccountEvents() throws Exception { data.setOneTime(character, "research_bronze", true); YamlConfiguration yaml = new YamlConfiguration(); - PlayersConfigsManager.write(data, yaml); + PlayersConfigsManager.write(data.snapshot(), yaml); YamlConfiguration reread = new YamlConfiguration(); reread.loadFromString(yaml.saveToString()); PlayerData loaded = PlayersConfigsManager.read(account, reread); @@ -54,8 +54,8 @@ void upstreamFilesStillLoadAndStrayCharacterKeysAreIgnored() throws Exception { PlayerData loaded = PlayersConfigsManager.read(account, yaml); assertTrue(loaded.isEventOneTime("codex_die")); - assertEquals(1, loaded.getCharacterEventData().size()); - assertTrue(loaded.getCharacterEventData().get(character).isEmpty()); + assertEquals(1, loaded.snapshot().characters().size()); + assertTrue(loaded.snapshot().characters().get(character).isEmpty()); } @Test diff --git a/src/test/java/net/tfminecraft/conditionalevents/managers/PlayerManagerScopeTest.java b/src/test/java/net/tfminecraft/conditionalevents/managers/PlayerManagerScopeTest.java index 1122832..d16a14f 100644 --- a/src/test/java/net/tfminecraft/conditionalevents/managers/PlayerManagerScopeTest.java +++ b/src/test/java/net/tfminecraft/conditionalevents/managers/PlayerManagerScopeTest.java @@ -2,6 +2,7 @@ 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; @@ -24,8 +25,6 @@ class PlayerManagerScopeTest { private CharacterTracker tracker; private PlayerManager players; private Player player; - private CEEvent research; - private CEEvent booster; @BeforeEach void setUp() { @@ -34,11 +33,10 @@ void setUp() { tracker = mock(CharacterTracker.class); when(plugin.getEventsManager()).thenReturn(events); when(plugin.getCharacterTracker()).thenReturn(tracker); - research = new CEEvent("research_bronze"); + CEEvent research = new CEEvent("research_bronze"); research.setCharacterScoped(true); - booster = new CEEvent("global_booster"); when(events.getEvent("research_bronze")).thenReturn(research); - when(events.getEvent("global_booster")).thenReturn(booster); + when(events.getEvent("global_booster")).thenReturn(new CEEvent("global_booster")); players = new PlayerManager(plugin); player = mock(Player.class); when(player.getUniqueId()).thenReturn(owner); @@ -47,27 +45,27 @@ void setUp() { } @Test - void perCharacterEventsWaitForACharacter() { - assertFalse(players.canRun(research, player)); - assertTrue(players.canRun(booster, player)); + 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)); - assertEquals(null, players.getPlayer(player, false)); + assertNull(players.getPlayer(player, false)); } @Test void perCharacterEventsUseThePlayedCharactersData() { when(tracker.character(owner)).thenReturn(Optional.of(character)); - assertTrue(players.canRun(research, player)); 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")); @@ -76,4 +74,17 @@ void perCharacterEventsUseThePlayedCharactersData() { 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/internal/ExecutedEventCharacterTest.java b/src/test/java/net/tfminecraft/conditionalevents/model/internal/ExecutedEventCharacterTest.java new file mode 100644 index 0000000..cc4459d --- /dev/null +++ b/src/test/java/net/tfminecraft/conditionalevents/model/internal/ExecutedEventCharacterTest.java @@ -0,0 +1,97 @@ +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.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; + +class ExecutedEventCharacterTest { + 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("codex_poi100"); + poi100.setCharacterScoped(true); + } + + @Test + void aRunCarriesOnWhileItsCharacterPlays() { + when(tracker.character(owner)).thenReturn(Optional.of(aldric)); + ExecutedEvent run = run(); + run.bindToCharacter(aldric, true, null); + + assertTrue(run.stillForItsCharacter()); + } + + @Test + void switchingDuringAWaitDropsTheRunAndUndoesItsMarks() { + players.setEventOneTime("codex_poi100", player, aldric, true); + players.setEventCooldown("codex_poi100", player, aldric, 500); + ExecutedEvent run = run(); + run.bindToCharacter(aldric, true, 100L); + when(tracker.character(owner)).thenReturn(Optional.of(mira)); + + assertFalse(run.stillForItsCharacter()); + assertFalse(run.stillForItsCharacter()); + + assertFalse(players.getEventOneTime("codex_poi100", player, aldric)); + assertEquals(100, players.getEventCooldown("codex_poi100", player, aldric)); + assertFalse(players.getEventOneTime("codex_poi100", player, mira)); + } + + @Test + void anAbandonedRunWithNothingToUndoLeavesDataAlone() { + players.setEventOneTime("codex_poi100", player, aldric, true); + ExecutedEvent run = run(); + run.bindToCharacter(aldric, false, null); + when(tracker.character(owner)).thenReturn(Optional.empty()); + + assertFalse(run.stillForItsCharacter()); + assertTrue(players.getEventOneTime("codex_poi100", player, aldric)); + } + + @Test + void accountRunsAreNeverDropped() { + ExecutedEvent run = run(); + run.bindToCharacter(null, true, null); + + assertTrue(run.stillForItsCharacter()); + } + + private ExecutedEvent run() { + return new ExecutedEvent(player, new ArrayList<>(), poi100, "default", null, null, plugin, null); + } +} diff --git a/src/test/java/net/tfminecraft/conditionalevents/model/player/PlayerDataTest.java b/src/test/java/net/tfminecraft/conditionalevents/model/player/PlayerDataTest.java index 40658a1..184d8a2 100644 --- a/src/test/java/net/tfminecraft/conditionalevents/model/player/PlayerDataTest.java +++ b/src/test/java/net/tfminecraft/conditionalevents/model/player/PlayerDataTest.java @@ -39,6 +39,37 @@ void resetsCoverEveryCharacter() { data.resetAll(); assertFalse(data.isEventOneTime(character, "codex_tf")); - assertTrue(data.getCharacterEventData().isEmpty()); + 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()); } } From e6a6f5a407d9a6744ea55915239e73c25bbc8e6a Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:08:43 +0000 Subject: [PATCH 5/7] Make character the default data scope Events with one_time or a cooldown now keep that data per character unless they set data_scope: account. Events without such data, and events without a player, have nothing to keep per character and run as upstream, so join, server-start and plain command events are unaffected. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 ++--- .../configs/ConfigsManager.java | 8 +++--- .../managers/EventsManager.java | 7 ++--- .../managers/PlayerManager.java | 2 +- .../conditionalevents/model/CEEvent.java | 13 ++++++++-- .../managers/PlayerManagerScopeTest.java | 7 +++-- .../model/CEEventScopeTest.java | 26 +++++++++++++++++++ 7 files changed, 55 insertions(+), 15 deletions(-) create mode 100644 src/test/java/net/tfminecraft/conditionalevents/model/CEEventScopeTest.java diff --git a/README.md b/README.md index 6145224..30a8131 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@ 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. An event with `data_scope: character` keeps its once-only and -cooldown data per roleplay character, and runs only while a character is in play; -other events keep it per account. This copy tracks upstream 4.80.3, builds for +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. diff --git a/src/main/java/net/tfminecraft/conditionalevents/configs/ConfigsManager.java b/src/main/java/net/tfminecraft/conditionalevents/configs/ConfigsManager.java index 547abb0..8b7120f 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/configs/ConfigsManager.java +++ b/src/main/java/net/tfminecraft/conditionalevents/configs/ConfigsManager.java @@ -64,7 +64,7 @@ public void configureEvents(){ List conditions = new ArrayList(); List actionGroups = new ArrayList(); boolean oneTime = false; - boolean characterScoped = false; + boolean characterScoped = true; String ignoreWithPermission = null; long cooldown = 0; boolean enabled = true; @@ -170,9 +170,9 @@ public void configureEvents(){ } if(config.contains(path+".data_scope")) { String scope = config.getString(path+".data_scope"); - characterScoped = "character".equalsIgnoreCase(scope); - if(!characterScoped && !"account".equalsIgnoreCase(scope)){ - plugin.getLogger().warning("Event "+key+" has data_scope "+scope+"; use account or character. Using account."); + 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")) { diff --git a/src/main/java/net/tfminecraft/conditionalevents/managers/EventsManager.java b/src/main/java/net/tfminecraft/conditionalevents/managers/EventsManager.java index bb1151a..be29d24 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/managers/EventsManager.java +++ b/src/main/java/net/tfminecraft/conditionalevents/managers/EventsManager.java @@ -62,10 +62,11 @@ public void checkSingularEvent(ConditionEvent conditionEvent,CEEvent event){ return; } - // Per-character events need a player playing a character, fixed here for the whole run. + // 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; - if(event.isCharacterScoped()){ - character = player == null ? null : plugin.getPlayerManager().playedCharacter(player); + if(player != null && event.usesCharacterData()){ + character = plugin.getPlayerManager().playedCharacter(player); if(character == null){ return; } diff --git a/src/main/java/net/tfminecraft/conditionalevents/managers/PlayerManager.java b/src/main/java/net/tfminecraft/conditionalevents/managers/PlayerManager.java index fc51bb9..c703377 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/managers/PlayerManager.java +++ b/src/main/java/net/tfminecraft/conditionalevents/managers/PlayerManager.java @@ -83,7 +83,7 @@ private record Scope(boolean available, UUID character) { private Scope scope(String eventName, Player player){ CEEvent event = plugin.getEventsManager().getEvent(eventName); - if(event == null || !event.isCharacterScoped()){ + if(event == null || !event.usesCharacterData()){ return Scope.ACCOUNT; } UUID character = playedCharacter(player); diff --git a/src/main/java/net/tfminecraft/conditionalevents/model/CEEvent.java b/src/main/java/net/tfminecraft/conditionalevents/model/CEEvent.java index 90cd7fd..55e2bec 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/model/CEEvent.java +++ b/src/main/java/net/tfminecraft/conditionalevents/model/CEEvent.java @@ -15,7 +15,7 @@ public class CEEvent { private List actionGroups; private boolean oneTime; - private boolean characterScoped; + private boolean characterScoped = true; private String ignoreWithPermission; @@ -74,11 +74,20 @@ public boolean isOneTime() { return oneTime; } - /** Whether one-time and cooldown data belong to the played character ({@code data_scope: character}). */ + /** 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; } diff --git a/src/test/java/net/tfminecraft/conditionalevents/managers/PlayerManagerScopeTest.java b/src/test/java/net/tfminecraft/conditionalevents/managers/PlayerManagerScopeTest.java index d16a14f..13e95a0 100644 --- a/src/test/java/net/tfminecraft/conditionalevents/managers/PlayerManagerScopeTest.java +++ b/src/test/java/net/tfminecraft/conditionalevents/managers/PlayerManagerScopeTest.java @@ -34,9 +34,12 @@ void setUp() { when(plugin.getEventsManager()).thenReturn(events); when(plugin.getCharacterTracker()).thenReturn(tracker); CEEvent research = new CEEvent("research_bronze"); - research.setCharacterScoped(true); + 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(new CEEvent("global_booster")); + when(events.getEvent("global_booster")).thenReturn(booster); players = new PlayerManager(plugin); player = mock(Player.class); when(player.getUniqueId()).thenReturn(owner); 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()); + } +} From ccef8b406a4dfd44922d68456669753b0cbeff8b Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:11:17 +0000 Subject: [PATCH 6/7] Address review: share run records with nested groups; undo safely - A per-character run's record (character, marks set, abandoned) is shared with every action group it calls through execute_action_group, so nested delayed actions are checked too and the undo happens once. - An abandoned run undoes only what it still owns: the one-time flag unless a staff reset happened since, and the cooldown only if it is still the start it wrote. - An undo touches only loaded data; it never creates an empty account that could overwrite the saved file. Co-Authored-By: Claude Opus 5.5 --- .../managers/EventsManager.java | 22 ++-- .../model/internal/CharacterRun.java | 59 ++++++++++ .../model/internal/ExecutedEvent.java | 46 ++++---- .../model/player/PlayerData.java | 27 +++++ .../conditionalevents/utils/ActionUtils.java | 8 +- .../internal/ExecutedEventCharacterTest.java | 103 +++++++++++++----- 6 files changed, 203 insertions(+), 62 deletions(-) create mode 100644 src/main/java/net/tfminecraft/conditionalevents/model/internal/CharacterRun.java diff --git a/src/main/java/net/tfminecraft/conditionalevents/managers/EventsManager.java b/src/main/java/net/tfminecraft/conditionalevents/managers/EventsManager.java index be29d24..32b9bc2 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/managers/EventsManager.java +++ b/src/main/java/net/tfminecraft/conditionalevents/managers/EventsManager.java @@ -65,11 +65,13 @@ public void checkSingularEvent(ConditionEvent conditionEvent,CEEvent event){ // 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 @@ -105,7 +107,7 @@ public void checkSingularEvent(ConditionEvent conditionEvent,CEEvent event){ ExecutedEvent executedEvent = new ExecutedEvent(player, conditionEvent.getEventVariables(), event, "one_time", conditionEvent.getMinecraftEvent(), conditionEvent.getTarget(), plugin, conditionEvent.getAdditionalEventStorage()); - executedEvent.bindToCharacter(character,false,null); + executedEvent.bindToCharacter(run); plugin.getDebugManager().sendActionsMessage(event.getName(),"one_time",player); executedEvent.executeActions(); return; @@ -122,7 +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(character,false,null); + executedEvent.bindToCharacter(run); plugin.getDebugManager().sendActionsMessage(event.getName(),"cooldown",player); executedEvent.executeActions(); return; @@ -130,8 +132,6 @@ public void checkSingularEvent(ConditionEvent conditionEvent,CEEvent event){ } } - boolean markedOneTime = false; - Long previousCooldown = null; if(player != null){ // Ignore parameters String actionGroupWithoutParameters = executeActionGroup; @@ -144,15 +144,21 @@ public void checkSingularEvent(ConditionEvent conditionEvent,CEEvent event){ if(event.isOneTime()){ if(!event.getPreventOneTimeActivationActionGroups().contains(actionGroupWithoutParameters)){ playerManager.setEventOneTime(event.getName(),player,character,true); - markedOneTime = true; + if(run != null){ + run.markedOneTime(playerManager.getPlayer(player,true).getResetEpoch()); + } } } //Set Cooldown if(event.getCooldown() != 0 && !bypassCooldown){ if(!event.getPreventCooldownActivationActionGroups().contains(actionGroupWithoutParameters)){ - previousCooldown = playerManager.getEventCooldown(event.getName(),player,character); - playerManager.setEventCooldown(event.getName(),player,character,System.currentTimeMillis()); + 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()); + } } } } @@ -161,7 +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(character,markedOneTime,previousCooldown); + executedEvent.bindToCharacter(run); executedEvent.executeActions(); } 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 05b9113..25d9bad 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/model/internal/ExecutedEvent.java +++ b/src/main/java/net/tfminecraft/conditionalevents/model/internal/ExecutedEvent.java @@ -20,7 +20,6 @@ import java.util.ArrayList; import java.util.List; -import java.util.UUID; // Represent an event that has accomplished all conditions and actions // are being executed. @@ -41,11 +40,8 @@ public class ExecutedEvent { private boolean onWait; private int currentActionPos; - // Per-character runs: the character they started for and what they set, to undo if it leaves. - private UUID character; - private boolean markedOneTime; - private Long previousCooldown; - private boolean abandoned; + // 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) { @@ -140,34 +136,34 @@ public void executeCancelActions(){ } /** - * Ties a per-character run 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 one-time flag and cooldown it set are undone, so the - * character can earn it again. {@code character} null leaves the run unbound. + * 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(UUID character, boolean markedOneTime, Long previousCooldown){ - this.character = character; - this.markedOneTime = markedOneTime; - this.previousCooldown = previousCooldown; + public void bindToCharacter(CharacterRun characterRun){ + this.characterRun = characterRun; } - /** Whether the run may carry on; drops it, once, when its character has left. */ + public CharacterRun getCharacterRun(){ + return characterRun; + } + + /** Whether the run may carry on; drops it when its character has left. */ boolean stillForItsCharacter(){ - if(abandoned){ - return false; - } - if(character == null || character.equals(plugin.getPlayerManager().playedCharacter(player))){ + if(characterRun == null){ return true; } - abandoned = true; + if(characterRun.isAbandoned()){ + return false; + } PlayerManager playerManager = plugin.getPlayerManager(); - if(markedOneTime){ - playerManager.setEventOneTime(event.getName(),player,character,false); + if(characterRun.character().equals(playerManager.playedCharacter(player))){ + return true; } - if(previousCooldown != null){ - playerManager.setEventCooldown(event.getName(),player,character,previousCooldown); + if(characterRun.abandon(playerManager.getPlayer(player,false),event.getName())){ + plugin.getDebugManager().sendActionsMessage(event.getName(),"abandoned: character left",player); } - plugin.getDebugManager().sendActionsMessage(event.getName(),"abandoned: character left",player); return false; } 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 cf3a732..8766156 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/model/player/PlayerData.java +++ b/src/main/java/net/tfminecraft/conditionalevents/model/player/PlayerData.java @@ -18,6 +18,7 @@ public class PlayerData { private String name; private boolean modified; private long revision; + private long resetEpoch; private ArrayList eventData; private final Map> characterEventData = new HashMap<>(); private final Object saveLock = new Object(); @@ -106,14 +107,40 @@ private void touched() { modified = true; } + /** Bumped by every staff reset, so an older run never undoes marks made after the reset. */ + public synchronized long getResetEpoch(){ + return resetEpoch; + } + + /** + * 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 != resetEpoch){ + 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){ + resetEpoch++; eventData.removeIf(c -> c.getName().equals(eventName)); characterEventData.values().forEach(list -> list.removeIf(c -> c.getName().equals(eventName))); touched(); } public synchronized void resetAll(){ + resetEpoch++; eventData.clear(); characterEventData.clear(); touched(); 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/test/java/net/tfminecraft/conditionalevents/model/internal/ExecutedEventCharacterTest.java b/src/test/java/net/tfminecraft/conditionalevents/model/internal/ExecutedEventCharacterTest.java index cc4459d..bd0108a 100644 --- a/src/test/java/net/tfminecraft/conditionalevents/model/internal/ExecutedEventCharacterTest.java +++ b/src/test/java/net/tfminecraft/conditionalevents/model/internal/ExecutedEventCharacterTest.java @@ -2,6 +2,8 @@ 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; @@ -20,8 +22,10 @@ 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(); @@ -43,55 +47,102 @@ void setUp() { player = mock(Player.class); when(player.getUniqueId()).thenReturn(owner); when(player.getName()).thenReturn("Steve"); - poi100 = new CEEvent("codex_poi100"); - poi100.setCharacterScoped(true); + poi100 = new CEEvent(EVENT); + poi100.setOneTime(true); + when(tracker.character(owner)).thenReturn(Optional.of(aldric)); } @Test void aRunCarriesOnWhileItsCharacterPlays() { - when(tracker.character(owner)).thenReturn(Optional.of(aldric)); - ExecutedEvent run = run(); - run.bindToCharacter(aldric, true, null); + ExecutedEvent run = run(new CharacterRun(aldric)); assertTrue(run.stillForItsCharacter()); } @Test - void switchingDuringAWaitDropsTheRunAndUndoesItsMarks() { - players.setEventOneTime("codex_poi100", player, aldric, true); - players.setEventCooldown("codex_poi100", player, aldric, 500); - ExecutedEvent run = run(); - run.bindToCharacter(aldric, true, 100L); + 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(run.stillForItsCharacter()); - assertFalse(run.stillForItsCharacter()); + assertFalse(group.stillForItsCharacter()); + assertFalse(parent.stillForItsCharacter()); - assertFalse(players.getEventOneTime("codex_poi100", player, aldric)); - assertEquals(100, players.getEventCooldown("codex_poi100", player, aldric)); - assertFalse(players.getEventOneTime("codex_poi100", player, mira)); + 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 anAbandonedRunWithNothingToUndoLeavesDataAlone() { - players.setEventOneTime("codex_poi100", player, aldric, true); - ExecutedEvent run = run(); - run.bindToCharacter(aldric, false, null); + 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 unloadedDataIsNotRecreatedByAnUndo() { + CharacterRun record = admit(null, null); + players.removePlayer(players.getPlayer(player, false)); when(tracker.character(owner)).thenReturn(Optional.empty()); - assertFalse(run.stillForItsCharacter()); - assertTrue(players.getEventOneTime("codex_poi100", player, aldric)); + 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() { - ExecutedEvent run = run(); - run.bindToCharacter(null, true, null); + when(tracker.character(owner)).thenReturn(Optional.empty()); - assertTrue(run.stillForItsCharacter()); + 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()); + if (setCooldown != null) { + players.setEventCooldown(EVENT, player, aldric, setCooldown); + record.markedCooldown(previousCooldown, setCooldown, data.getResetEpoch()); + } + return record; } - private ExecutedEvent run() { - return new ExecutedEvent(player, new ArrayList<>(), poi100, "default", null, null, plugin, null); + private ExecutedEvent run(CharacterRun record) { + ExecutedEvent run = new ExecutedEvent(player, new ArrayList<>(), poi100, "default", null, null, plugin, null); + run.bindToCharacter(record); + return run; } } From 9864f4c0a5355259b346a6d853eeb7c6472c0ae1 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:14:18 +0000 Subject: [PATCH 7/7] Address review: track staff resets per event Resetting one event no longer stops an abandoned run of another event from undoing its marks. A reset of the same event, or of all events, still does. Co-Authored-By: Claude Opus 5.5 --- .../managers/EventsManager.java | 4 +-- .../model/player/PlayerData.java | 18 ++++++++----- .../internal/ExecutedEventCharacterTest.java | 27 +++++++++++++++++-- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/main/java/net/tfminecraft/conditionalevents/managers/EventsManager.java b/src/main/java/net/tfminecraft/conditionalevents/managers/EventsManager.java index 32b9bc2..300bba1 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/managers/EventsManager.java +++ b/src/main/java/net/tfminecraft/conditionalevents/managers/EventsManager.java @@ -145,7 +145,7 @@ public void checkSingularEvent(ConditionEvent conditionEvent,CEEvent event){ if(!event.getPreventOneTimeActivationActionGroups().contains(actionGroupWithoutParameters)){ playerManager.setEventOneTime(event.getName(),player,character,true); if(run != null){ - run.markedOneTime(playerManager.getPlayer(player,true).getResetEpoch()); + run.markedOneTime(playerManager.getPlayer(player,true).getResetEpoch(event.getName())); } } } @@ -157,7 +157,7 @@ public void checkSingularEvent(ConditionEvent conditionEvent,CEEvent event){ long now = System.currentTimeMillis(); playerManager.setEventCooldown(event.getName(),player,character,now); if(run != null){ - run.markedCooldown(previous,now,playerManager.getPlayer(player,true).getResetEpoch()); + run.markedCooldown(previous,now,playerManager.getPlayer(player,true).getResetEpoch(event.getName())); } } } 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 8766156..f4077c3 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/model/player/PlayerData.java +++ b/src/main/java/net/tfminecraft/conditionalevents/model/player/PlayerData.java @@ -18,7 +18,8 @@ public class PlayerData { private String name; private boolean modified; private long revision; - private long resetEpoch; + private long resetAllCount; + private final Map resetCounts = new HashMap<>(); private ArrayList eventData; private final Map> characterEventData = new HashMap<>(); private final Object saveLock = new Object(); @@ -107,9 +108,12 @@ private void touched() { modified = true; } - /** Bumped by every staff reset, so an older run never undoes marks made after the reset. */ - public synchronized long getResetEpoch(){ - return resetEpoch; + /** + * 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); } /** @@ -119,7 +123,7 @@ public synchronized long getResetEpoch(){ 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 != resetEpoch){ + if(e == null || epoch != getResetEpoch(eventName)){ return; } if(oneTime){ @@ -133,14 +137,14 @@ public synchronized void undoRun(UUID character, String eventName, boolean oneTi /** Resets the event for the account and every character. */ public synchronized void resetEvent(String eventName){ - resetEpoch++; + 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(){ - resetEpoch++; + resetAllCount++; eventData.clear(); characterEventData.clear(); touched(); diff --git a/src/test/java/net/tfminecraft/conditionalevents/model/internal/ExecutedEventCharacterTest.java b/src/test/java/net/tfminecraft/conditionalevents/model/internal/ExecutedEventCharacterTest.java index bd0108a..37dabd5 100644 --- a/src/test/java/net/tfminecraft/conditionalevents/model/internal/ExecutedEventCharacterTest.java +++ b/src/test/java/net/tfminecraft/conditionalevents/model/internal/ExecutedEventCharacterTest.java @@ -99,6 +99,29 @@ void anOldRunLeavesFlagsEarnedAfterAStaffResetAlone() { 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); @@ -132,10 +155,10 @@ 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()); + record.markedOneTime(data.getResetEpoch(EVENT)); if (setCooldown != null) { players.setEventCooldown(EVENT, player, aldric, setCooldown); - record.markedCooldown(previousCooldown, setCooldown, data.getResetEpoch()); + record.markedCooldown(previousCooldown, setCooldown, data.getResetEpoch(EVENT)); } return record; }