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