From f4e4edc88b4c04142d36e03345fbf30ed1138ea9 Mon Sep 17 00:00:00 2001 From: JustinasLa <95593194+JustinasLa@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:02:53 +0200 Subject: [PATCH] feat: award activity points for MMOCore profession XP Adds an optional `profession:` key on an activity so an admin can set a weekly goal like "gain 500 xp in Forager". Profession ids are normalised the same way MMOCore normalises them (lowercase, `_` and space to `-`), so a config key copied from the profession filename matches. MMOCore multiplies profession XP by boosters, so gains are routinely fractional. Flooring each event would permanently under-count and would never credit a profession tuned below 1 xp per action, so the listener carries the remainder per player per activity and evicts it on quit. Entries ship commented out: without MMOCore they would render as GUI rows stuck at zero. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build.yml | 4 +- libs/SHA256SUMS | 2 + pom.xml | 20 +++ .../tfmc/justin/activity/ActivityPlugin.java | 7 ++ .../config/ActivityConfiguration.java | 59 ++++++++- .../listeners/ProfessionXpListener.java | 115 ++++++++++++++++++ src/main/resources/config.yml | 30 +++++ src/main/resources/plugin.yml | 2 +- .../config/ActivityConfigurationTest.java | 78 ++++++++++++ .../listeners/ProfessionXpListenerTest.java | 75 ++++++++++++ 10 files changed, 389 insertions(+), 3 deletions(-) create mode 100644 src/main/java/tfmc/justin/activity/listeners/ProfessionXpListener.java create mode 100644 src/test/java/tfmc/justin/activity/config/ActivityConfigurationTest.java create mode 100644 src/test/java/tfmc/justin/activity/listeners/ProfessionXpListenerTest.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 78bbb60..43286ea 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,7 +37,9 @@ jobs: gh release download v1 --repo JustinasLa/tfmc-deps --dir libs --clobber \ --pattern 'VotingPlugin-7.0.jar' \ --pattern 'musicalinstruments-2.5.jar' \ - --pattern 'geiger_counter-1.1.3.jar' + --pattern 'geiger_counter-1.1.3.jar' \ + --pattern 'MMOCore-1.13.1.jar' \ + --pattern 'MythicLib-dist-1.7.1.jar' - name: Verify private dependencies run: sha256sum -c libs/SHA256SUMS diff --git a/libs/SHA256SUMS b/libs/SHA256SUMS index 67a6969..7a7a5ca 100644 --- a/libs/SHA256SUMS +++ b/libs/SHA256SUMS @@ -1,3 +1,5 @@ 19d6bdfc6799c8cd3ac117ce5e9e4569df2199fcf3201ffb6425bf775cfc20ee *libs/VotingPlugin-7.0.jar aaf20bbeba7963cec543ba1206ca0b1c6304efaa4607f3cb85e6602caa4d2319 *libs/musicalinstruments-2.5.jar efd60fad17496e296501c7f48215ba026a4a0bf25e9961c5c6026c9615586e9d *libs/geiger_counter-1.1.3.jar +81d511d0830951f988615b6533cc93e82d00a83fb31e8ccbb36441ae3e8157ef *libs/MMOCore-1.13.1.jar +e89a612e566e9b7dc4f43228adb8f6e560f293ea4107bae259c6dfd6732d7a70 *libs/MythicLib-dist-1.7.1.jar diff --git a/pom.xml b/pom.xml index 9c00077..a9883f5 100644 --- a/pom.xml +++ b/pom.xml @@ -72,6 +72,26 @@ ${project.basedir}/libs/geiger_counter-1.1.3.jar + + + local + MMOCore + 1.13.1 + system + ${project.basedir}/libs/MMOCore-1.13.1.jar + + + + + local + MythicLib + 1.7.1 + system + ${project.basedir}/libs/MythicLib-dist-1.7.1.jar + + org.junit.jupiter diff --git a/src/main/java/tfmc/justin/activity/ActivityPlugin.java b/src/main/java/tfmc/justin/activity/ActivityPlugin.java index c78eeb6..ff2a673 100644 --- a/src/main/java/tfmc/justin/activity/ActivityPlugin.java +++ b/src/main/java/tfmc/justin/activity/ActivityPlugin.java @@ -8,6 +8,7 @@ import tfmc.justin.activity.listeners.GeigerListener; import tfmc.justin.activity.listeners.InstrumentListener; import tfmc.justin.activity.listeners.JoinListener; +import tfmc.justin.activity.listeners.ProfessionXpListener; import tfmc.justin.activity.listeners.VoteListener; import tfmc.justin.activity.managers.ActivityManager; @@ -82,6 +83,12 @@ private void registerHooks() { getLogger().info("Hooked into geiger_counter."); } + if (Bukkit.getPluginManager().isPluginEnabled("MMOCore")) { + getServer().getPluginManager().registerEvents( + new ProfessionXpListener(ActivityManager.getInstance()), this); + getLogger().info("Hooked into MMOCore."); + } + if (Bukkit.getPluginManager().isPluginEnabled("PlaceholderAPI")) { placeholderHook = new PlaceholderHook(ActivityManager.getInstance()); placeholderHook.register(); diff --git a/src/main/java/tfmc/justin/activity/config/ActivityConfiguration.java b/src/main/java/tfmc/justin/activity/config/ActivityConfiguration.java index b798c39..42adef9 100644 --- a/src/main/java/tfmc/justin/activity/config/ActivityConfiguration.java +++ b/src/main/java/tfmc/justin/activity/config/ActivityConfiguration.java @@ -34,6 +34,15 @@ public class ActivityConfiguration { // ==================================== private volatile Map activities = new LinkedHashMap<>(); + // ==================================== + // MMOCore profession id (normalized, see normalizeProfessionId) -> + // activity id. Built once on load so the experience-gain listener, which + // fires on every ore broken and every crop harvested, is a single map + // lookup instead of a scan. + // Replaced wholesale alongside 'activities' for the same reason. + // ==================================== + private volatile Map professionActivities = Map.of(); + // ==================================== // volatile: read from PlaceholderAPI's own threads after /activity reload // rebuilds them on the main thread, so readers need a visibility guarantee. @@ -105,10 +114,12 @@ private void loadActivities(ConfigurationSection section) { if (section == null) { plugin.getLogger().warning("config.yml has no 'activities' section - the bar can never fill."); activities = new LinkedHashMap<>(); + professionActivities = Map.of(); return; } Map loaded = new LinkedHashMap<>(); + Map professions = new LinkedHashMap<>(); for (String id : section.getKeys(false)) { ConfigurationSection entry = section.getConfigurationSection(id); if (entry == null) { @@ -122,18 +133,41 @@ private void loadActivities(ConfigurationSection section) { continue; } + int dailyCap = Math.max(0, wholeNumber(entry, id, "daily-cap", 0)); + loaded.put(id, new ActivityDef( id, entry.getString("display", id), material(entry.getString("material", "PAPER"), "activities." + id + ".material"), Math.max(1, wholeNumber(entry, id, "every", 1)), points, - Math.max(0, wholeNumber(entry, id, "daily-cap", 0)) + dailyCap )); + + String profession = entry.getString("profession"); + if (profession != null && !profession.isBlank()) { + String key = normalizeProfessionId(profession); + String previous = professions.put(key, id); + if (previous != null) { + plugin.getLogger().warning("Activities '" + previous + "' and '" + id + + "' both track profession '" + key + "' - only '" + id + "' will be fed."); + } + // ==================================== + // Profession XP is game-influenced and can be hundreds per + // event, unlike the one-per-action activities, so an + // uncapped profession activity is an unbounded reward source. + // ==================================== + if (dailyCap <= 0) { + plugin.getLogger().warning("Activity '" + id + "' tracks profession '" + key + + "' with no daily-cap - profession XP is unbounded, so this activity can" + + " fill the bar on its own."); + } + } } // One assignment publishes the whole set activities = loaded; + professionActivities = professions; } // ==================================== @@ -224,6 +258,29 @@ public ActivityDef activity(String id) { return activities.get(id); } + // The activity fed by an MMOCore profession, or null if none tracks it + public String professionActivity(String professionId) { + return professionActivity(professionActivities, professionId); + } + + static String professionActivity(Map professions, String professionId) { + if (professionId == null || professionId.isBlank()) { + return null; + } + return professions.get(normalizeProfessionId(professionId)); + } + + // ==================================== + // MMOCore's Profession constructor stores its id as + // lowercase-with-underscores-and-spaces-turned-into-dashes, so a file + // named mining_expert.yml has the id 'mining-expert'. config.yml tells + // admins to use the file name, so both the map keys and the lookups run + // through here - anything else silently never matches. + // ==================================== + static String normalizeProfessionId(String professionId) { + return professionId.trim().toLowerCase(Locale.ROOT).replace('_', '-').replace(' ', '-'); + } + // ==================================== // Both keys off one clock reading. Asking for them separately can straddle // a midnight tick and produce a day key from the new day with a week key diff --git a/src/main/java/tfmc/justin/activity/listeners/ProfessionXpListener.java b/src/main/java/tfmc/justin/activity/listeners/ProfessionXpListener.java new file mode 100644 index 0000000..5edf5c2 --- /dev/null +++ b/src/main/java/tfmc/justin/activity/listeners/ProfessionXpListener.java @@ -0,0 +1,115 @@ +package tfmc.justin.activity.listeners; + +import net.Indyuce.mmocore.api.event.PlayerExperienceGainEvent; +import net.Indyuce.mmocore.experience.Profession; +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 tfmc.justin.activity.managers.ActivityManager; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +// ==================================== +// Feeds MMOCore profession XP into whichever activity declares +// 'profession: ' in config.yml. Only constructed when MMOCore is enabled +// - see ActivityPlugin. +// +// MONITOR + ignoreCancelled: the XP only counts once every other plugin has +// had its say and the gain is actually going through. +// ==================================== +public class ProfessionXpListener implements Listener { + + private final ActivityManager manager; + + // ==================================== + // Leftover fraction of an XP point per player per activity, so a boosted + // 5.5 xp gain is not repeatedly rounded down to 5. Kept per activity + // because each has its own goal and daily cap - a mining fraction must + // not be credited to fishing. + // + // A plain HashMap is safe: PlayerExperienceGainEvent is not async, and + // Bukkit refuses to deliver a non-async event off the primary thread, so + // MMOCore would throw before ever reaching this listener. + // + // In-memory only - the worst a restart costs a player is under one point. + // ==================================== + private final Map> carry = new HashMap<>(); + + public ProfessionXpListener(ActivityManager manager) { + this.manager = manager; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onExperienceGain(PlayerExperienceGainEvent event) { + // Without a profession this is main-class/character XP, which no + // activity tracks + if (!event.hasProfession()) { + return; + } + + Profession profession = event.getProfession(); + if (profession == null) { + return; + } + + String activityId = manager.getConfiguration().professionActivity(profession.getId()); + if (activityId == null) { + return; + } + + Player player = event.getPlayer(); + if (player == null) { + return; + } + + UUID uuid = player.getUniqueId(); + Map byActivity = carry.computeIfAbsent(uuid, key -> new HashMap<>()); + Credit credit = credit(byActivity.getOrDefault(activityId, 0.0), event.getExperience()); + byActivity.put(activityId, credit.carry()); + + if (credit.amount() > 0) { + manager.recordAction(uuid, activityId, credit.amount()); + } + } + + // Nothing to carry for a player who is gone + @EventHandler + public void onQuit(PlayerQuitEvent event) { + carry.remove(event.getPlayer().getUniqueId()); + } + + // Whole points to record now, and the fraction left over for next time + record Credit(int amount, double carry) { + } + + // ==================================== + // Pure: (leftover fraction, this event's XP) -> what to record. The + // incoming XP is sanitized first so a poisoned value cannot corrupt the + // stored carry, which always stays in [0, 1). + // ==================================== + static Credit credit(double carry, double experience) { + double total = carry + sanitize(experience); + double whole = Math.floor(total); + if (whole >= Integer.MAX_VALUE) { + return new Credit(Integer.MAX_VALUE, 0); + } + return new Credit((int) whole, total - whole); + } + + // ==================================== + // getExperience() is a double and other plugins can set it: NaN, a + // negative (an XP penalty) and infinity are all worth nothing. The + // comparison is written this way round so NaN fails it. Huge values are + // capped rather than overflowing recordAction's int. + // ==================================== + private static double sanitize(double experience) { + if (!(experience > 0)) { + return 0; + } + return Math.min(experience, Integer.MAX_VALUE); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 6f49267..8202447 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -6,6 +6,13 @@ reset: # activity can add per day (0 = no cap). Bar caps at bar.max. # vote: one point per vote site. quest: fed by the quests plugin via # /activity add quest 1 +# profession: optional, MMOCore only. Ties the activity to one MMOCore +# profession id (as named by plugins/MMOCore/professions/.yml; case, +# underscores and spaces do not matter) and counts XP gained in it - so +# 'every' is XP, not actions. Fractional XP is carried over between gains +# rather than lost. Two activities naming the same profession is a config +# error and only the last one is fed. Always set a daily-cap on these: +# profession XP is unbounded, unlike the one-per-action activities. activities: vote: display: "&aVoting" @@ -31,6 +38,29 @@ activities: every: 20 points: 1 daily-cap: 1 + # MMOCore professions - uncomment to enable. They need MMOCore installed; + # without it they show in /activity but can never be filled. + #profession_crafter: + # display: "&6Crafter XP" + # material: CRAFTING_TABLE + # every: 500 # 500 profession XP → 1 point + # points: 1 + # daily-cap: 2 + # profession: crafter + #profession_forager: + # display: "&2Forager XP" + # material: WHEAT + # every: 500 + # points: 1 + # daily-cap: 2 + # profession: forager + #profession_herborist: + # display: "&aHerborist XP" + # material: FERN + # every: 500 + # points: 1 + # daily-cap: 2 + # profession: herborist # Console commands run once per milestone (every bar.reward-every points) # when the player clicks the reward chest in /activity. diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index c1d48fe..15f74dc 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -3,7 +3,7 @@ version: 1.0 name: activity author: Justin api-version: 1.21 -softdepend: [VotingPlugin, MusicalInstruments, geiger_counter, PlaceholderAPI] +softdepend: [VotingPlugin, MusicalInstruments, geiger_counter, MMOCore, PlaceholderAPI] commands: activity: description: Open the weekly activity bar, or run admin subcommands diff --git a/src/test/java/tfmc/justin/activity/config/ActivityConfigurationTest.java b/src/test/java/tfmc/justin/activity/config/ActivityConfigurationTest.java new file mode 100644 index 0000000..8ca9a57 --- /dev/null +++ b/src/test/java/tfmc/justin/activity/config/ActivityConfigurationTest.java @@ -0,0 +1,78 @@ +package tfmc.justin.activity.config; + +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +// ==================================== +// The profession id normalization and the lookup that uses it. load() itself +// needs a running server, so the map is built here the same way loadActivities +// builds it - through normalizeProfessionId. +// ==================================== +class ActivityConfigurationTest { + + private static Map professions(String... professionIds) { + Map map = new LinkedHashMap<>(); + for (String professionId : professionIds) { + map.put(ActivityConfiguration.normalizeProfessionId(professionId), "activity_" + professionId); + } + return map; + } + + @Test + void normalizationMatchesMmoCore() { + // MMOCore: id = rawId.toLowerCase().replace("_", "-").replace(" ", "-") + assertEquals("mining-expert", ActivityConfiguration.normalizeProfessionId("mining_expert")); + assertEquals("mining-expert", ActivityConfiguration.normalizeProfessionId("Mining Expert")); + assertEquals("mining-expert", ActivityConfiguration.normalizeProfessionId("MINING-EXPERT")); + assertEquals("crafter", ActivityConfiguration.normalizeProfessionId(" Crafter ")); + } + + @Test + void normalizationIsIdempotent() { + String once = ActivityConfiguration.normalizeProfessionId("Mining_Expert"); + assertEquals(once, ActivityConfiguration.normalizeProfessionId(once)); + } + + // The whole point of MEDIUM 1: a config file written the way config.yml + // tells admins to write it must still find MMOCore's dashed id + @Test + void underscoredConfigIdMatchesDashedMmoCoreId() { + Map professions = professions("mining_expert"); + assertEquals("activity_mining_expert", + ActivityConfiguration.professionActivity(professions, "mining-expert")); + } + + @Test + void lookupIsCaseAndSeparatorInsensitive() { + Map professions = professions("crafter"); + assertEquals("activity_crafter", ActivityConfiguration.professionActivity(professions, "CRAFTER")); + assertEquals("activity_crafter", ActivityConfiguration.professionActivity(professions, " crafter ")); + } + + @Test + void unknownBlankAndNullProfessionsAreNotTracked() { + Map professions = professions("crafter"); + assertNull(ActivityConfiguration.professionActivity(professions, "mining")); + assertNull(ActivityConfiguration.professionActivity(professions, "")); + assertNull(ActivityConfiguration.professionActivity(professions, " ")); + assertNull(ActivityConfiguration.professionActivity(professions, null)); + assertNull(ActivityConfiguration.professionActivity(Map.of(), "crafter")); + } + + // Documented behaviour: two activities on one profession, last one wins + @Test + void duplicateProfessionKeepsTheLastActivity() { + Map professions = new LinkedHashMap<>(); + professions.put(ActivityConfiguration.normalizeProfessionId("crafter"), "first"); + String previous = professions.put(ActivityConfiguration.normalizeProfessionId("CRAFTER"), "second"); + + assertEquals("first", previous); + assertEquals(1, professions.size()); + assertEquals("second", ActivityConfiguration.professionActivity(professions, "crafter")); + } +} diff --git a/src/test/java/tfmc/justin/activity/listeners/ProfessionXpListenerTest.java b/src/test/java/tfmc/justin/activity/listeners/ProfessionXpListenerTest.java new file mode 100644 index 0000000..f301983 --- /dev/null +++ b/src/test/java/tfmc/justin/activity/listeners/ProfessionXpListenerTest.java @@ -0,0 +1,75 @@ +package tfmc.justin.activity.listeners; + +import org.junit.jupiter.api.Test; +import tfmc.justin.activity.listeners.ProfessionXpListener.Credit; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +// Only the XP conversion: the listener itself needs a running server +class ProfessionXpListenerTest { + + @Test + void wholeAndFractionalXpFloors() { + assertEquals(1, ProfessionXpListener.credit(0, 1.0).amount()); + assertEquals(1, ProfessionXpListener.credit(0, 1.9).amount()); + assertEquals(500, ProfessionXpListener.credit(0, 500.4).amount()); + } + + @Test + void nothingWorthRecordingIsZero() { + assertEquals(0, ProfessionXpListener.credit(0, 0.9).amount()); + assertEquals(0, ProfessionXpListener.credit(0, 0).amount()); + assertEquals(0, ProfessionXpListener.credit(0, -50).amount()); + assertEquals(0, ProfessionXpListener.credit(0, Double.NaN).amount()); + assertEquals(0, ProfessionXpListener.credit(0, Double.NEGATIVE_INFINITY).amount()); + } + + @Test + void absurdValuesClampInsteadOfOverflowing() { + assertEquals(Integer.MAX_VALUE, ProfessionXpListener.credit(0, 1e18).amount()); + assertEquals(Integer.MAX_VALUE, ProfessionXpListener.credit(0, Double.POSITIVE_INFINITY).amount()); + assertEquals(0, ProfessionXpListener.credit(0.5, Double.POSITIVE_INFINITY).carry()); + } + + @Test + void theFractionIsCarriedNotDiscarded() { + Credit first = ProfessionXpListener.credit(0, 5.5); + assertEquals(5, first.amount()); + assertEquals(0.5, first.carry(), 1e-9); + + Credit second = ProfessionXpListener.credit(first.carry(), 5.5); + assertEquals(6, second.amount()); + assertEquals(0.0, second.carry(), 1e-9); + } + + // A profession tuned below 1 xp per action must still credit eventually + @Test + void repeatedSubOneGainsEventuallyCredit() { + double carry = 0; + int total = 0; + for (int i = 0; i < 10; i++) { + Credit credit = ProfessionXpListener.credit(carry, 0.5); + carry = credit.carry(); + total += credit.amount(); + } + assertEquals(5, total); + } + + // A poisoned value contributes nothing and leaves the carry intact + @Test + void poisonedValuesCannotCorruptTheCarry() { + assertEquals(0.5, ProfessionXpListener.credit(0.5, Double.NaN).carry(), 1e-9); + assertEquals(0.5, ProfessionXpListener.credit(0.5, -1e9).carry(), 1e-9); + assertEquals(0, ProfessionXpListener.credit(0.5, Double.NaN).amount()); + } + + @Test + void carryNeverReachesAWholePoint() { + double carry = 0; + for (int i = 0; i < 50; i++) { + Credit credit = ProfessionXpListener.credit(carry, 0.7); + carry = credit.carry(); + org.junit.jupiter.api.Assertions.assertTrue(carry >= 0 && carry < 1, "carry out of range: " + carry); + } + } +}