Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions libs/SHA256SUMS
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,26 @@
<systemPath>${project.basedir}/libs/geiger_counter-1.1.3.jar</systemPath>
</dependency>

<!-- MMOCore: jar-only release, compiled against for PlayerExperienceGainEvent -->
<dependency>
<groupId>local</groupId>
<artifactId>MMOCore</artifactId>
<version>1.13.1</version>
<scope>system</scope>
<systemPath>${project.basedir}/libs/MMOCore-1.13.1.jar</systemPath>
</dependency>

<!-- MythicLib: MMOCore's own hard dependency. Not used directly, but
MMOCore's Profession implements one of its interfaces, so javac
needs it on the classpath to resolve Profession at all. -->
<dependency>
<groupId>local</groupId>
<artifactId>MythicLib</artifactId>
<version>1.7.1</version>
<scope>system</scope>
<systemPath>${project.basedir}/libs/MythicLib-dist-1.7.1.jar</systemPath>
</dependency>

<!-- Tests -->
<dependency>
<groupId>org.junit.jupiter</groupId>
Expand Down
7 changes: 7 additions & 0 deletions src/main/java/tfmc/justin/activity/ActivityPlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ public class ActivityConfiguration {
// ====================================
private volatile Map<String, ActivityDef> 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<String, String> 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.
Expand Down Expand Up @@ -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<String, ActivityDef> loaded = new LinkedHashMap<>();
Map<String, String> professions = new LinkedHashMap<>();
for (String id : section.getKeys(false)) {
ConfigurationSection entry = section.getConfigurationSection(id);
if (entry == null) {
Expand All @@ -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;
}

// ====================================
Expand Down Expand Up @@ -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<String, String> 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
Expand Down
115 changes: 115 additions & 0 deletions src/main/java/tfmc/justin/activity/listeners/ProfessionXpListener.java
Original file line number Diff line number Diff line change
@@ -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: <id>' 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<UUID, Map<String, Double>> 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<String, Double> 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);
}
}
30 changes: 30 additions & 0 deletions src/main/resources/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <player> quest 1
# profession: optional, MMOCore only. Ties the activity to one MMOCore
# profession id (as named by plugins/MMOCore/professions/<id>.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"
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/plugin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading