From d991515226dca564d0a5cd31432375ba28e7c545 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:31:03 +0200 Subject: [PATCH 1/2] feat: share one-time events between an account's characters A per-character one_time event with share_one_time: true counts as done for every character of an account once one of them has done it. Shortly after a session starts, the character gets each such event another character has done and runs its shared action group, for example to carry the event's Player EXP. If the character leaves before that run ends, the flag is undone and shared again at its next session. Co-Authored-By: Claude Opus 5.5 --- .../conditionalevents/ConditionalEvents.java | 2 +- .../character/CharacterTracker.java | 36 +++++++- .../character/OneTimeSharing.java | 25 +++++ .../configs/ConfigsManager.java | 5 + .../managers/OneTimeSharingManager.java | 58 ++++++++++++ .../conditionalevents/model/CEEvent.java | 13 +++ .../model/player/PlayerData.java | 21 +++++ .../character/CharacterTrackerTest.java | 91 ++++++++++++++++++- .../model/CEEventScopeTest.java | 15 +++ .../model/player/PlayerDataTest.java | 23 +++++ 10 files changed, 283 insertions(+), 6 deletions(-) create mode 100644 src/main/java/net/tfminecraft/conditionalevents/character/OneTimeSharing.java create mode 100644 src/main/java/net/tfminecraft/conditionalevents/managers/OneTimeSharingManager.java diff --git a/src/main/java/net/tfminecraft/conditionalevents/ConditionalEvents.java b/src/main/java/net/tfminecraft/conditionalevents/ConditionalEvents.java index 65522ee..8565286 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/ConditionalEvents.java +++ b/src/main/java/net/tfminecraft/conditionalevents/ConditionalEvents.java @@ -68,7 +68,7 @@ public void onEnable(){ this.characterTracker = new CharacterTracker(playerManager, name -> { CEEvent event = eventsManager.getEvent(name); return event != null && event.usesCharacterData(); - }, new LegacyRecipient(getDataFolder().getParentFile().toPath()), getLogger()); + }, new LegacyRecipient(getDataFolder().getParentFile().toPath()), new OneTimeSharingManager(this), getLogger()); registerEvents(); registerCommands(); diff --git a/src/main/java/net/tfminecraft/conditionalevents/character/CharacterTracker.java b/src/main/java/net/tfminecraft/conditionalevents/character/CharacterTracker.java index 40e9e66..aae97ee 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/character/CharacterTracker.java +++ b/src/main/java/net/tfminecraft/conditionalevents/character/CharacterTracker.java @@ -1,6 +1,7 @@ package net.tfminecraft.conditionalevents.character; import java.io.IOException; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -27,19 +28,26 @@ * Which character each player is playing, readable from any thread, since events such as chat run * off the main thread. On an account's first session it also gives the account's old data for * per-character events to its most-played character, matching what Codex does with old journals. + * Shortly after a session starts, the character gets the shared one-time events (see + * {@link OneTimeSharing}) another of the account's characters has done. */ public final class CharacterTracker implements Listener { + /** Shared events arrive after the character's class and body are in place, and once the screen settles. */ + static final long SHARE_DELAY_TICKS = 40L; + private final Map current = new ConcurrentHashMap<>(); private final PlayerManager players; private final Predicate perCharacter; private final LegacyRecipient recipients; + private final OneTimeSharing sharing; private final Logger logger; public CharacterTracker(PlayerManager players, Predicate perCharacter, LegacyRecipient recipients, - Logger logger) { + OneTimeSharing sharing, Logger logger) { this.players = players; this.perCharacter = perCharacter; this.recipients = recipients; + this.sharing = sharing; this.logger = logger; } @@ -51,13 +59,13 @@ public Optional character(UUID 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())); + Characters.currentSession(player).ifPresent(session -> begin(player, session.ref())); } } @EventHandler(priority = EventPriority.LOWEST) public void onStart(CharacterSessionStartEvent event) { - begin(event.getSession().ref()); + begin(event.getPlayer(), event.getSession().ref()); } @EventHandler(priority = EventPriority.MONITOR) @@ -71,9 +79,29 @@ public void onQuit(PlayerQuitEvent event) { current.remove(event.getPlayer().getUniqueId()); } - private void begin(CharacterRef ref) { + private void begin(Player player, CharacterRef ref) { seed(ref.ownerId()); current.put(ref.ownerId(), ref.characterId()); + sharing.later(SHARE_DELAY_TICKS, () -> share(player, ref)); + } + + /** + * Marks the shared events another character of the account has done as done for this one and + * runs their {@code shared} action group, while that character is still in play. + */ + void share(Player player, CharacterRef ref) { + PlayerData data = players.getPlayerByUUID(ref.ownerId()); + if (!player.isOnline() || data == null || !ref.characterId().equals(current.get(ref.ownerId()))) { + return; + } + List shared = data.share(ref.characterId(), sharing.sharedEvents()); + for (String event : shared) { + sharing.run(player, ref.characterId(), event, data.getResetEpoch(event)); + } + if (!shared.isEmpty()) { + logger.info("Shared " + shared.size() + " one-time events of account " + ref.ownerId() + + " with its character " + ref.characterId()); + } } /** diff --git a/src/main/java/net/tfminecraft/conditionalevents/character/OneTimeSharing.java b/src/main/java/net/tfminecraft/conditionalevents/character/OneTimeSharing.java new file mode 100644 index 0000000..a4ab75e --- /dev/null +++ b/src/main/java/net/tfminecraft/conditionalevents/character/OneTimeSharing.java @@ -0,0 +1,25 @@ +package net.tfminecraft.conditionalevents.character; + +import java.util.Collection; +import java.util.UUID; + +import org.bukkit.entity.Player; + +/** + * What sharing one-time events needs from the rest of the plugin. A per-character one-time event with + * {@code share_one_time: true} is done for every character of an account once one of them has done it; + * the others run its {@code shared} action group instead, for example to get its Player EXP. + */ +public interface OneTimeSharing { + /** The names of the events whose one-time flag is shared. */ + Collection sharedEvents(); + + /** + * Runs the event's {@code shared} action group for the player, if it has one, as a run of the + * character it was shared with, so the flag is undone if that character leaves before it ends. + */ + void run(Player player, UUID character, String event, long resetEpoch); + + /** Runs the task on the main thread after the given number of ticks. */ + void later(long ticks, Runnable task); +} diff --git a/src/main/java/net/tfminecraft/conditionalevents/configs/ConfigsManager.java b/src/main/java/net/tfminecraft/conditionalevents/configs/ConfigsManager.java index 8b7120f..e6a388b 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/configs/ConfigsManager.java +++ b/src/main/java/net/tfminecraft/conditionalevents/configs/ConfigsManager.java @@ -65,6 +65,7 @@ public void configureEvents(){ List actionGroups = new ArrayList(); boolean oneTime = false; boolean characterScoped = true; + boolean shareOneTime = false; String ignoreWithPermission = null; long cooldown = 0; boolean enabled = true; @@ -175,6 +176,9 @@ public void configureEvents(){ plugin.getLogger().warning("Event "+key+" has data_scope "+scope+"; use character or account. Using character."); } } + if(config.contains(path+".share_one_time")) { + shareOneTime = Boolean.valueOf(config.getString(path+".share_one_time")); + } if(config.contains(path+".enabled")) { enabled = Boolean.valueOf(config.getString(path+".enabled")); } @@ -199,6 +203,7 @@ public void configureEvents(){ event.setIgnoreWithPermission(ignoreWithPermission); event.setOneTime(oneTime); event.setCharacterScoped(characterScoped); + event.setShareOneTime(shareOneTime); event.setEnabled(enabled); event.setIgnoreIfCancelled(ignoreIfCancelled); event.setPreventCooldownActivationActionGroups(preventCooldownActivationActionGroups); diff --git a/src/main/java/net/tfminecraft/conditionalevents/managers/OneTimeSharingManager.java b/src/main/java/net/tfminecraft/conditionalevents/managers/OneTimeSharingManager.java new file mode 100644 index 0000000..8c97967 --- /dev/null +++ b/src/main/java/net/tfminecraft/conditionalevents/managers/OneTimeSharingManager.java @@ -0,0 +1,58 @@ +package net.tfminecraft.conditionalevents.managers; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import net.tfminecraft.conditionalevents.ConditionalEvents; +import net.tfminecraft.conditionalevents.character.OneTimeSharing; +import net.tfminecraft.conditionalevents.model.CEEvent; +import net.tfminecraft.conditionalevents.model.internal.AdditionalEventStorage; +import net.tfminecraft.conditionalevents.model.internal.CharacterRun; +import net.tfminecraft.conditionalevents.model.internal.ExecutedEvent; + +/** The plugin's side of shared one-time events: which events share, and their {@code shared} action group. */ +public class OneTimeSharingManager implements OneTimeSharing { + public static final String SHARED_GROUP = "shared"; + + private final ConditionalEvents plugin; + + public OneTimeSharingManager(ConditionalEvents plugin) { + this.plugin = plugin; + } + + @Override + public Collection sharedEvents() { + List names = new ArrayList<>(); + for (CEEvent event : plugin.getEventsManager().getEvents()) { + if (event.isEnabled() && event.sharesOneTime()) { + names.add(event.getName()); + } + } + return names; + } + + @Override + public void run(Player player, UUID character, String eventName, long resetEpoch) { + CEEvent event = plugin.getEventsManager().getEvent(eventName); + if (event == null || event.getActionGroup(SHARED_GROUP) == null) { + return; + } + CharacterRun run = new CharacterRun(character); + run.markedOneTime(resetEpoch); + ExecutedEvent executed = new ExecutedEvent(player, new ArrayList<>(), event, SHARED_GROUP, null, null, plugin, + new AdditionalEventStorage()); + executed.bindToCharacter(run); + plugin.getDebugManager().sendActionsMessage(eventName, SHARED_GROUP, player); + executed.executeActions(); + } + + @Override + public void later(long ticks, Runnable task) { + Bukkit.getScheduler().runTaskLater(plugin, task, ticks); + } +} diff --git a/src/main/java/net/tfminecraft/conditionalevents/model/CEEvent.java b/src/main/java/net/tfminecraft/conditionalevents/model/CEEvent.java index 55e2bec..18aa4dd 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/model/CEEvent.java +++ b/src/main/java/net/tfminecraft/conditionalevents/model/CEEvent.java @@ -16,6 +16,7 @@ public class CEEvent { private boolean oneTime; private boolean characterScoped = true; + private boolean shareOneTime; private String ignoreWithPermission; @@ -92,6 +93,18 @@ public void setCharacterScoped(boolean characterScoped) { this.characterScoped = characterScoped; } + /** + * Whether a character gets this per-character one-time event once any other character of its + * account has done it ({@code share_one_time: true}), running its {@code shared} action group. + */ + public boolean sharesOneTime() { + return shareOneTime && oneTime && characterScoped; + } + + public void setShareOneTime(boolean shareOneTime) { + this.shareOneTime = shareOneTime; + } + 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 71b5535..076aa66 100644 --- a/src/main/java/net/tfminecraft/conditionalevents/model/player/PlayerData.java +++ b/src/main/java/net/tfminecraft/conditionalevents/model/player/PlayerData.java @@ -1,6 +1,7 @@ package net.tfminecraft.conditionalevents.model.player; import java.util.ArrayList; +import java.util.Collection; import java.util.HashMap; import java.util.Iterator; import java.util.List; @@ -110,6 +111,26 @@ public synchronized int seed(UUID character, Predicate events) { return moved; } + /** + * Marks done for the character each of the events that another of the account's characters has + * done and it has not. + * @return the events marked, in the order given + */ + public synchronized List share(UUID character, Collection events) { + List shared = new ArrayList<>(); + for (String event : events) { + if (!isEventOneTime(character, event) && characterEventData.keySet().stream() + .anyMatch(other -> !other.equals(character) && isEventOneTime(other, event))) { + shared.add(event); + } + } + // Marked after the search: the character's first mark adds it to the map being searched. + for (String event : shared) { + setOneTime(character, event, true); + } + return shared; + } + public synchronized Snapshot snapshot() { Map> characters = new HashMap<>(); characterEventData.forEach((character, events) -> characters.put(character, copy(events))); diff --git a/src/test/java/net/tfminecraft/conditionalevents/character/CharacterTrackerTest.java b/src/test/java/net/tfminecraft/conditionalevents/character/CharacterTrackerTest.java index 5bbccad..e9f2366 100644 --- a/src/test/java/net/tfminecraft/conditionalevents/character/CharacterTrackerTest.java +++ b/src/test/java/net/tfminecraft/conditionalevents/character/CharacterTrackerTest.java @@ -51,6 +51,39 @@ class CharacterTrackerTest { private PlayerManager players; private CharacterTracker tracker; private Player player; + private FakeSharing sharing; + + private record Run(UUID character, String event, long epoch) { + } + + private static final class FakeSharing implements OneTimeSharing { + List events = List.of(); + final List delays = new ArrayList<>(); + final List tasks = new ArrayList<>(); + final List runs = new ArrayList<>(); + + @Override + public java.util.Collection sharedEvents() { + return events; + } + + @Override + public void run(Player player, UUID character, String event, long resetEpoch) { + runs.add(new Run(character, event, resetEpoch)); + } + + @Override + public void later(long ticks, Runnable task) { + delays.add(ticks); + tasks.add(task); + } + + void runAll() { + List due = new ArrayList<>(tasks); + tasks.clear(); + due.forEach(Runnable::run); + } + } @BeforeEach void setUp() { @@ -72,7 +105,9 @@ public void flush() { public void close() { } }); - tracker = new CharacterTracker(players, Set.of("research_bronze")::contains, new LegacyRecipient(plugins), logger); + sharing = new FakeSharing(); + tracker = new CharacterTracker(players, Set.of("research_bronze")::contains, new LegacyRecipient(plugins), sharing, + logger); player = mock(Player.class); when(player.getUniqueId()).thenReturn(owner); } @@ -173,6 +208,60 @@ void noSeedWithoutDataOrRosterAndNothingToMoveIsQuiet() { assertTrue(logs.isEmpty()); } + @Test + void aCharacterGetsTheSharedEventsItsAccountHasDone() { + sharing.events = List.of("duchy_1", "duchy_2", "duchy_3"); + when(player.isOnline()).thenReturn(true); + PlayerData data = new PlayerData(owner, "Steve"); + data.setSeededTo(other); + data.setOneTime(other, "duchy_1", true); + data.setOneTime(other, "duchy_2", true); + data.setOneTime(alt, "duchy_2", true); + data.setOneTime("duchy_3", true); + data.resetEvent("duchy_1"); + data.setOneTime(other, "duchy_1", true); + players.addPlayer(data); + + tracker.onStart(start(alt)); + assertEquals(List.of(CharacterTracker.SHARE_DELAY_TICKS), sharing.delays); + assertFalse(data.isEventOneTime(alt, "duchy_1"), "Nothing arrives before the delay"); + sharing.runAll(); + + assertTrue(data.isEventOneTime(alt, "duchy_1")); + assertFalse(data.isEventOneTime(alt, "duchy_3"), "Account-level data is not a character's"); + assertEquals(List.of(new Run(alt, "duchy_1", 1)), sharing.runs, "Only what the character lacked runs"); + assertTrue(logs.stream().anyMatch(r -> r.getMessage().contains( + "Shared 1 one-time events of account " + owner + " with its character " + alt))); + + tracker.onStart(start(alt)); + sharing.runAll(); + assertEquals(1, sharing.runs.size(), "Nothing is shared twice"); + } + + @Test + void sharingWaitsForTheSameCharacterToStillBeInPlay() { + sharing.events = List.of("duchy_1"); + PlayerData data = new PlayerData(owner, "Steve"); + data.setSeededTo(other); + data.setOneTime(other, "duchy_1", true); + + tracker.onStart(start(alt)); + sharing.runAll(); + players.addPlayer(data); + tracker.onStart(start(alt)); + sharing.runAll(); + when(player.isOnline()).thenReturn(true); + tracker.onStart(start(alt)); + tracker.onEnd(end(alt)); + sharing.runAll(); + tracker.onStart(start(alt)); + tracker.onStart(start(other)); + sharing.runAll(); + + assertFalse(data.isEventOneTime(alt, "duchy_1")); + assertTrue(sharing.runs.isEmpty(), "No player data, an offline player or a character no longer in play get nothing"); + } + private CharacterInfo played(UUID character, long seconds) { return new CharacterInfo(new CharacterRef(owner, character), "Name", CharacterStatus.ALIVE, null, Set.of(), seconds, null); } diff --git a/src/test/java/net/tfminecraft/conditionalevents/model/CEEventScopeTest.java b/src/test/java/net/tfminecraft/conditionalevents/model/CEEventScopeTest.java index 518a0b5..a0f2079 100644 --- a/src/test/java/net/tfminecraft/conditionalevents/model/CEEventScopeTest.java +++ b/src/test/java/net/tfminecraft/conditionalevents/model/CEEventScopeTest.java @@ -23,4 +23,19 @@ void characterIsTheDefaultButOnlyMattersForEventsWithData() { meditate.setCharacterScoped(false); assertFalse(meditate.usesCharacterData()); } + + @Test + void onlyPerCharacterOneTimeEventsShareTheirFlag() { + CEEvent duchy = new CEEvent("sf_title_enter_1"); + duchy.setShareOneTime(true); + assertFalse(duchy.sharesOneTime(), "Without one_time there is nothing to share"); + duchy.setOneTime(true); + assertTrue(duchy.sharesOneTime()); + duchy.setCharacterScoped(false); + assertFalse(duchy.sharesOneTime(), "Account data is shared already"); + + CEEvent unlock = new CEEvent("codex_die"); + unlock.setOneTime(true); + assertFalse(unlock.sharesOneTime()); + } } 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 184d8a2..43c13ae 100644 --- a/src/test/java/net/tfminecraft/conditionalevents/model/player/PlayerDataTest.java +++ b/src/test/java/net/tfminecraft/conditionalevents/model/player/PlayerDataTest.java @@ -72,4 +72,27 @@ void snapshotsAreCopies() { data.setModified(true); assertTrue(data.isModified()); } + + @Test + void sharingMarksOnlyWhatAnotherCharacterHasDone() { + UUID mira = UUID.randomUUID(); + UUID aldric = UUID.randomUUID(); + PlayerData data = new PlayerData(UUID.randomUUID(), "Steve"); + data.setOneTime(mira, "duchy_1", true); + data.setOneTime(mira, "duchy_2", true); + data.setOneTime(mira, "duchy_4", false); + data.setOneTime(aldric, "duchy_2", true); + data.setOneTime("duchy_3", true); + data.markSaved(data.snapshot().revision()); + + assertEquals(java.util.List.of("duchy_1"), data.share(aldric, java.util.List.of("duchy_1", "duchy_2", "duchy_3", "duchy_4"))); + assertTrue(data.isEventOneTime(aldric, "duchy_1")); + assertFalse(data.isEventOneTime(aldric, "duchy_3")); + assertTrue(data.isModified()); + + UUID fresh = UUID.randomUUID(); + assertEquals(java.util.List.of("duchy_1", "duchy_2"), data.share(fresh, java.util.List.of("duchy_1", "duchy_2")), + "A character with no data yet gets everything"); + assertEquals(java.util.List.of(), data.share(fresh, java.util.List.of("duchy_1", "duchy_2"))); + } } From 754b7ac8dca27ca15181c223b9d16a3b2763111d Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:49:28 +0200 Subject: [PATCH 2/2] docs: describe share_one_time in the README Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 30a8131..826f7c8 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,9 @@ 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. 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 +keeps an event's data per account instead, and `share_one_time: true` gives a +one-time event to every character of an account once one of them has done it, +running its `shared` action group. 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.