bridge for plugins
This commit is contained in:
@@ -22,6 +22,7 @@ import net.kewwbec.networkcore.command.network.HubCommand;
|
|||||||
import net.kewwbec.networkcore.command.network.LockServerCommand;
|
import net.kewwbec.networkcore.command.network.LockServerCommand;
|
||||||
import net.kewwbec.networkcore.command.network.SendAllCommand;
|
import net.kewwbec.networkcore.command.network.SendAllCommand;
|
||||||
import net.kewwbec.networkcore.command.network.SendCommand;
|
import net.kewwbec.networkcore.command.network.SendCommand;
|
||||||
|
import net.kewwbec.networkcore.command.network.XpCommand;
|
||||||
import net.kewwbec.networkcore.config.ConfigLoader;
|
import net.kewwbec.networkcore.config.ConfigLoader;
|
||||||
import net.kewwbec.networkcore.config.CoreConfig;
|
import net.kewwbec.networkcore.config.CoreConfig;
|
||||||
import net.kewwbec.networkcore.db.MySqlDatabaseService;
|
import net.kewwbec.networkcore.db.MySqlDatabaseService;
|
||||||
@@ -37,6 +38,11 @@ import net.kewwbec.networkcore.network.LockEnforcer;
|
|||||||
import net.kewwbec.networkcore.network.LockService;
|
import net.kewwbec.networkcore.network.LockService;
|
||||||
import net.kewwbec.networkcore.network.RefRequestService;
|
import net.kewwbec.networkcore.network.RefRequestService;
|
||||||
import net.kewwbec.networkcore.network.ShutdownHandoff;
|
import net.kewwbec.networkcore.network.ShutdownHandoff;
|
||||||
|
import net.kewwbec.networkcore.api.xp.LevelCurve;
|
||||||
|
import net.kewwbec.networkcore.api.xp.NetworkXp;
|
||||||
|
import net.kewwbec.networkcore.xp.NetworkXpServiceImpl;
|
||||||
|
import net.kewwbec.networkcore.xp.XpRepository;
|
||||||
|
import net.kewwbec.networkcore.xp.XpSchemaBootstrap;
|
||||||
import net.kewwbec.networkcore.presence.PlayerPresenceService;
|
import net.kewwbec.networkcore.presence.PlayerPresenceService;
|
||||||
import net.kewwbec.networkcore.registry.RedisServerRegistry;
|
import net.kewwbec.networkcore.registry.RedisServerRegistry;
|
||||||
import net.kewwbec.networkcore.rpc.RpcClientImpl;
|
import net.kewwbec.networkcore.rpc.RpcClientImpl;
|
||||||
@@ -44,6 +50,7 @@ import net.kewwbec.networkcore.rpc.RpcClientImpl;
|
|||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.sql.SQLException;
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
|
|
||||||
public final class NetworkCore extends JavaPlugin {
|
public final class NetworkCore extends JavaPlugin {
|
||||||
@@ -184,6 +191,24 @@ public final class NetworkCore extends JavaPlugin {
|
|||||||
getCommandRegistry().registerCommand(new SendAllCommand(serverRegistry, presenceService, refRequestService, serverId));
|
getCommandRegistry().registerCommand(new SendAllCommand(serverRegistry, presenceService, refRequestService, serverId));
|
||||||
getCommandRegistry().registerCommand(new LockServerCommand(lockService, serverRegistry, serverId));
|
getCommandRegistry().registerCommand(new LockServerCommand(lockService, serverRegistry, serverId));
|
||||||
|
|
||||||
|
if (config.xp.enabled) {
|
||||||
|
if (database == null || !database.isHealthy()) {
|
||||||
|
LOGGER.at(Level.WARNING).log("XP service requires a healthy MySQL DatabaseService; skipping (xp.enabled=true but mysql is unavailable)");
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
XpSchemaBootstrap.apply(database.getDataSource(), config.xp);
|
||||||
|
XpRepository xpRepo = new XpRepository(database.getDataSource(), config.xp.table);
|
||||||
|
LevelCurve curve = LevelCurve.fromBase(config.xp.level_curve_base);
|
||||||
|
NetworkXpServiceImpl xpService = new NetworkXpServiceImpl(xpRepo, curve, messageBus, config.xp, serverId);
|
||||||
|
services.register(NetworkXp.class, xpService);
|
||||||
|
getCommandRegistry().registerCommand(new XpCommand(xpService, presenceService));
|
||||||
|
LOGGER.at(Level.INFO).log("NetworkXp service ready (table=%s, curve base=%d)", config.xp.table, config.xp.level_curve_base);
|
||||||
|
} catch (SQLException e) {
|
||||||
|
((HytaleLogger.Api) LOGGER.at(Level.SEVERE).withCause(e)).log("Failed to bootstrap XP schema; XP disabled this run");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LOGGER.at(Level.INFO).log("NetworkCore started");
|
LOGGER.at(Level.INFO).log("NetworkCore started");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,6 +277,23 @@ public final class NetworkCore extends JavaPlugin {
|
|||||||
public DatabaseService getDatabase() { return services.get(DatabaseService.class); }
|
public DatabaseService getDatabase() { return services.get(DatabaseService.class); }
|
||||||
@Nullable
|
@Nullable
|
||||||
public PlayerPresence getPlayerPresence() { return services.get(PlayerPresence.class); }
|
public PlayerPresence getPlayerPresence() { return services.get(PlayerPresence.class); }
|
||||||
|
@Nullable
|
||||||
|
public net.kewwbec.networkcore.api.ranks.RankLookup getRankLookup() {
|
||||||
|
return services.get(net.kewwbec.networkcore.api.ranks.RankLookup.class);
|
||||||
|
}
|
||||||
|
@Nullable
|
||||||
|
public net.kewwbec.networkcore.api.guilds.GuildLookup getGuildLookup() {
|
||||||
|
return services.get(net.kewwbec.networkcore.api.guilds.GuildLookup.class);
|
||||||
|
}
|
||||||
|
@Nullable
|
||||||
|
public NetworkXp getNetworkXp() { return services.get(NetworkXp.class); }
|
||||||
|
@Nullable
|
||||||
|
public RefRequestService getRefRequestService() { return refRequestService; }
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public <T> T findService(@Nonnull Class<T> type) {
|
||||||
|
return services.get(type);
|
||||||
|
}
|
||||||
|
|
||||||
@Nonnull
|
@Nonnull
|
||||||
public CoreConfig getConfigSnapshot() {
|
public CoreConfig getConfigSnapshot() {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public final class NetworkCorePermissions {
|
|||||||
public static final String SENDALL = ROOT + ".sendall";
|
public static final String SENDALL = ROOT + ".sendall";
|
||||||
public static final String LOCKSERVER = ROOT + ".lockserver";
|
public static final String LOCKSERVER = ROOT + ".lockserver";
|
||||||
public static final String LOCKSERVER_BYPASS = ROOT + ".lockserver.bypass";
|
public static final String LOCKSERVER_BYPASS = ROOT + ".lockserver.bypass";
|
||||||
|
public static final String XP_ADMIN = ROOT + ".xp.admin";
|
||||||
|
|
||||||
private NetworkCorePermissions() {
|
private NetworkCorePermissions() {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package net.kewwbec.networkcore.api.guilds;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal projection of a guild for cross-plugin consumption. Implemented by the Social plugin
|
||||||
|
* (or any plugin that provides guild data) and registered through {@link GuildLookup}.
|
||||||
|
*/
|
||||||
|
public record GuildInfo(
|
||||||
|
long id,
|
||||||
|
@Nonnull String name,
|
||||||
|
@Nullable String tag,
|
||||||
|
@Nonnull String memberRankName
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package net.kewwbec.networkcore.api.guilds;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public lookup for a player's guild membership. Registered by the Social plugin in NetworkCore's
|
||||||
|
* service registry; consumers obtain it via {@code core.getGuildLookup()} and degrade gracefully
|
||||||
|
* to null if no provider is registered.
|
||||||
|
*/
|
||||||
|
public interface GuildLookup {
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
GuildInfo getGuildOf(@Nonnull UUID uuid);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package net.kewwbec.networkcore.api.ranks;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal projection of a rank for cross-plugin consumption. Implemented by the Ranks plugin (or
|
||||||
|
* any plugin that provides ranks) and registered through {@link RankLookup}.
|
||||||
|
*/
|
||||||
|
public record RankInfo(
|
||||||
|
@Nonnull String id,
|
||||||
|
@Nonnull String displayName,
|
||||||
|
@Nonnull String prefix,
|
||||||
|
@Nullable String color
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package net.kewwbec.networkcore.api.ranks;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public lookup for a player's primary rank. Registered by the Ranks plugin in NetworkCore's
|
||||||
|
* service registry; consumers (Social, Hub, etc.) obtain it via {@code core.getRankLookup()} and
|
||||||
|
* gracefully degrade to defaults if no provider is registered.
|
||||||
|
*/
|
||||||
|
public interface RankLookup {
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
RankInfo getPrimaryRank(@Nonnull UUID uuid);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package net.kewwbec.networkcore.api.xp;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triangular level curve. Reaching level L requires base * L*(L+1)/2 total XP, so advancing from
|
||||||
|
* level L-1 to L requires exactly base * L XP. With base=100:
|
||||||
|
* L1=100, L2=300, L3=600, L5=1500, L10=5500, L20=21000.
|
||||||
|
*
|
||||||
|
* Pass the configured base to {@link #fromBase(long)} once at startup; the resulting instance is
|
||||||
|
* stateless and thread-safe.
|
||||||
|
*/
|
||||||
|
public final class LevelCurve {
|
||||||
|
|
||||||
|
private final long base;
|
||||||
|
|
||||||
|
private LevelCurve(long base) {
|
||||||
|
this.base = Math.max(1L, base);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
public static LevelCurve fromBase(long base) {
|
||||||
|
return new LevelCurve(base);
|
||||||
|
}
|
||||||
|
|
||||||
|
public long base() {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total XP needed to reach exactly this level. Level 0 = 0 XP.
|
||||||
|
*/
|
||||||
|
public long totalForLevel(int level) {
|
||||||
|
if (level <= 0) return 0L;
|
||||||
|
long L = level;
|
||||||
|
return base * (L * (L + 1) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Level achieved given a total XP. Level 0 means below the first threshold.
|
||||||
|
*/
|
||||||
|
public int levelForTotal(long totalXp) {
|
||||||
|
if (totalXp <= 0) return 0;
|
||||||
|
// total >= base * L*(L+1)/2 -> L <= (-1 + sqrt(1 + 8*total/base)) / 2
|
||||||
|
double t = (double) totalXp / (double) base;
|
||||||
|
double l = Math.floor((-1.0 + Math.sqrt(1.0 + 8.0 * t)) / 2.0);
|
||||||
|
int level = (int) l;
|
||||||
|
// numeric guard against floating-point edge cases
|
||||||
|
while (totalForLevel(level + 1) <= totalXp) level++;
|
||||||
|
while (level > 0 && totalForLevel(level) > totalXp) level--;
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* XP cost to advance from the current level to the next. Equals base * (level + 1).
|
||||||
|
*/
|
||||||
|
public long xpForNextLevel(int level) {
|
||||||
|
return base * (long) (level + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
public LevelInfo info(long totalXp) {
|
||||||
|
int level = levelForTotal(totalXp);
|
||||||
|
long floor = totalForLevel(level);
|
||||||
|
return new LevelInfo(totalXp, level, totalXp - floor, xpForNextLevel(level));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package net.kewwbec.networkcore.api.xp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot of a player's XP state.
|
||||||
|
*
|
||||||
|
* @param totalXp cumulative XP earned
|
||||||
|
* @param level current level derived from {@code totalXp}
|
||||||
|
* @param xpIntoLevel progress through the current level
|
||||||
|
* @param xpForNextLevel XP required to advance from {@code level} to {@code level + 1}
|
||||||
|
*/
|
||||||
|
public record LevelInfo(
|
||||||
|
long totalXp,
|
||||||
|
int level,
|
||||||
|
long xpIntoLevel,
|
||||||
|
long xpForNextLevel
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package net.kewwbec.networkcore.api.xp;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global network XP for any player on the network. Backed by NetworkCore's MySQL pool.
|
||||||
|
*
|
||||||
|
* Any plugin can:
|
||||||
|
* - read a player's total XP and level
|
||||||
|
* - award XP (relative) when something XP-worthy happens
|
||||||
|
* - set XP (absolute) for admin tools
|
||||||
|
*
|
||||||
|
* Level transitions are detected automatically; when a player's level changes from a mutation, a
|
||||||
|
* level-up event is published on the configured bus channel so plugins can react across servers
|
||||||
|
* (e.g. surface a chat announcement, hand out a reward, refresh UI).
|
||||||
|
*
|
||||||
|
* Mutations are persisted synchronously. Callers running on a Hytale world thread should hop to
|
||||||
|
* a worker if they expect to do many at once (the DB call is fast but not free).
|
||||||
|
*/
|
||||||
|
public interface NetworkXp {
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
LevelCurve curve();
|
||||||
|
|
||||||
|
long getTotal(@Nonnull UUID uuid);
|
||||||
|
|
||||||
|
int getLevel(@Nonnull UUID uuid);
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
LevelInfo getInfo(@Nonnull UUID uuid);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add {@code delta} XP (can be negative). Returns the new total. {@code source} is an
|
||||||
|
* informational tag carried in the level-up event if one fires.
|
||||||
|
*/
|
||||||
|
long add(@Nonnull UUID uuid, long delta, @Nullable String source);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the total XP to {@code total} (clamped to >= 0). Returns the new total.
|
||||||
|
*/
|
||||||
|
long set(@Nonnull UUID uuid, long total, @Nullable String source);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package net.kewwbec.networkcore.api.xp;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-server announcement that a player's level changed. Published whenever
|
||||||
|
* {@link NetworkXp#add} or {@link NetworkXp#set} produces a level transition. Plugins subscribe
|
||||||
|
* via NetworkCore's MessageBus on the configured channel (default {@code network.xp.levelup}).
|
||||||
|
*/
|
||||||
|
public final class PlayerLevelUpPayload {
|
||||||
|
|
||||||
|
public String uuid;
|
||||||
|
public int oldLevel;
|
||||||
|
public int newLevel;
|
||||||
|
public long totalXp;
|
||||||
|
public String source;
|
||||||
|
public String fromServerId;
|
||||||
|
|
||||||
|
public PlayerLevelUpPayload() {}
|
||||||
|
|
||||||
|
public PlayerLevelUpPayload(@Nonnull String uuid,
|
||||||
|
int oldLevel,
|
||||||
|
int newLevel,
|
||||||
|
long totalXp,
|
||||||
|
@Nullable String source,
|
||||||
|
@Nullable String fromServerId) {
|
||||||
|
this.uuid = uuid;
|
||||||
|
this.oldLevel = oldLevel;
|
||||||
|
this.newLevel = newLevel;
|
||||||
|
this.totalXp = totalXp;
|
||||||
|
this.source = source;
|
||||||
|
this.fromServerId = fromServerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package net.kewwbec.networkcore.command.network;
|
||||||
|
|
||||||
|
import com.hypixel.hytale.component.Ref;
|
||||||
|
import com.hypixel.hytale.component.Store;
|
||||||
|
import com.hypixel.hytale.server.core.Message;
|
||||||
|
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
||||||
|
import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg;
|
||||||
|
import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg;
|
||||||
|
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
|
||||||
|
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection;
|
||||||
|
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand;
|
||||||
|
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||||
|
import com.hypixel.hytale.server.core.universe.world.World;
|
||||||
|
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||||
|
import net.kewwbec.networkcore.NetworkCorePermissions;
|
||||||
|
import net.kewwbec.networkcore.api.PlayerLocation;
|
||||||
|
import net.kewwbec.networkcore.api.PlayerPresence;
|
||||||
|
import net.kewwbec.networkcore.api.xp.LevelInfo;
|
||||||
|
import net.kewwbec.networkcore.api.xp.NetworkXp;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /xp [status [player] | give <player> <amount> | set <player> <total>]
|
||||||
|
*
|
||||||
|
* Reading is free for anyone; mutations require networkcore.xp.admin. Targeting uses
|
||||||
|
* PlayerPresence to find an online player by name.
|
||||||
|
*/
|
||||||
|
public final class XpCommand extends AbstractCommandCollection {
|
||||||
|
|
||||||
|
public XpCommand(@Nonnull NetworkXp xp, @Nonnull PlayerPresence presence) {
|
||||||
|
super("xp", "Show or modify network XP");
|
||||||
|
addSubCommand(new Status(xp, presence));
|
||||||
|
addSubCommand(new Give(xp, presence));
|
||||||
|
addSubCommand(new Set(xp, presence));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override protected boolean canGeneratePermission() { return false; }
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private static UUID resolve(@Nonnull PlayerPresence presence, @Nonnull String name) {
|
||||||
|
PlayerLocation loc = presence.findByName(name);
|
||||||
|
return loc == null ? null : loc.uuid();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class Status extends AbstractPlayerCommand {
|
||||||
|
private final NetworkXp xp;
|
||||||
|
private final PlayerPresence presence;
|
||||||
|
private final OptionalArg<String> nameArg = withOptionalArg("player", "Player name (default: you)", ArgTypes.STRING);
|
||||||
|
|
||||||
|
public Status(@Nonnull NetworkXp xp, @Nonnull PlayerPresence presence) {
|
||||||
|
super("status", "Show a player's XP and level");
|
||||||
|
this.xp = xp;
|
||||||
|
this.presence = presence;
|
||||||
|
}
|
||||||
|
@Override protected boolean canGeneratePermission() { return false; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void execute(@Nonnull CommandContext ctx, @Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef sender, @Nonnull World world) {
|
||||||
|
String name = nameArg.get(ctx);
|
||||||
|
UUID target = sender.getUuid();
|
||||||
|
String label = sender.getUsername();
|
||||||
|
if (name != null && !name.isBlank()) {
|
||||||
|
UUID found = resolve(presence, name);
|
||||||
|
if (found == null) {
|
||||||
|
ctx.sendMessage(Message.raw(name + " is not online.").color(Color.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target = found;
|
||||||
|
label = name;
|
||||||
|
}
|
||||||
|
LevelInfo info = xp.getInfo(target);
|
||||||
|
ctx.sendMessage(Message.raw(label + ": level " + info.level()
|
||||||
|
+ " (" + info.xpIntoLevel() + "/" + info.xpForNextLevel()
|
||||||
|
+ ", total " + info.totalXp() + ")").color(Color.WHITE));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class Give extends AbstractPlayerCommand {
|
||||||
|
private final NetworkXp xp;
|
||||||
|
private final PlayerPresence presence;
|
||||||
|
private final RequiredArg<String> nameArg = withRequiredArg("player", "Player name", ArgTypes.STRING);
|
||||||
|
private final RequiredArg<Integer> amountArg = withRequiredArg("amount", "XP to add (can be negative)", ArgTypes.INTEGER);
|
||||||
|
|
||||||
|
public Give(@Nonnull NetworkXp xp, @Nonnull PlayerPresence presence) {
|
||||||
|
super("give", "Give XP to a player");
|
||||||
|
this.xp = xp;
|
||||||
|
this.presence = presence;
|
||||||
|
requirePermission(NetworkCorePermissions.XP_ADMIN);
|
||||||
|
}
|
||||||
|
@Override protected boolean canGeneratePermission() { return false; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void execute(@Nonnull CommandContext ctx, @Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef sender, @Nonnull World world) {
|
||||||
|
String name = nameArg.get(ctx);
|
||||||
|
Integer amount = amountArg.get(ctx);
|
||||||
|
if (name == null || amount == null) {
|
||||||
|
ctx.sendMessage(Message.raw("Usage: /xp give <player> <amount>").color(Color.YELLOW));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
UUID target = resolve(presence, name);
|
||||||
|
if (target == null) {
|
||||||
|
ctx.sendMessage(Message.raw(name + " is not online.").color(Color.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long after = xp.add(target, amount, "admin:" + sender.getUsername());
|
||||||
|
ctx.sendMessage(Message.raw("Added " + amount + " XP to " + name + " (new total: " + after + ").").color(Color.GREEN));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class Set extends AbstractPlayerCommand {
|
||||||
|
private final NetworkXp xp;
|
||||||
|
private final PlayerPresence presence;
|
||||||
|
private final RequiredArg<String> nameArg = withRequiredArg("player", "Player name", ArgTypes.STRING);
|
||||||
|
private final RequiredArg<Integer> totalArg = withRequiredArg("total", "Absolute XP total to set", ArgTypes.INTEGER);
|
||||||
|
|
||||||
|
public Set(@Nonnull NetworkXp xp, @Nonnull PlayerPresence presence) {
|
||||||
|
super("set", "Set a player's absolute XP total");
|
||||||
|
this.xp = xp;
|
||||||
|
this.presence = presence;
|
||||||
|
requirePermission(NetworkCorePermissions.XP_ADMIN);
|
||||||
|
}
|
||||||
|
@Override protected boolean canGeneratePermission() { return false; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void execute(@Nonnull CommandContext ctx, @Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef sender, @Nonnull World world) {
|
||||||
|
String name = nameArg.get(ctx);
|
||||||
|
Integer total = totalArg.get(ctx);
|
||||||
|
if (name == null || total == null) {
|
||||||
|
ctx.sendMessage(Message.raw("Usage: /xp set <player> <total>").color(Color.YELLOW));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
UUID target = resolve(presence, name);
|
||||||
|
if (target == null) {
|
||||||
|
ctx.sendMessage(Message.raw(name + " is not online.").color(Color.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long after = xp.set(target, total, "admin:" + sender.getUsername());
|
||||||
|
ctx.sendMessage(Message.raw("Set " + name + " to " + after + " XP.").color(Color.GREEN));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ public final class ConfigLoader {
|
|||||||
if (parsed.metrics == null) parsed.metrics = new CoreConfig.MetricsSection();
|
if (parsed.metrics == null) parsed.metrics = new CoreConfig.MetricsSection();
|
||||||
if (parsed.rpc == null) parsed.rpc = new CoreConfig.RpcSection();
|
if (parsed.rpc == null) parsed.rpc = new CoreConfig.RpcSection();
|
||||||
if (parsed.mysql == null) parsed.mysql = new CoreConfig.MysqlSection();
|
if (parsed.mysql == null) parsed.mysql = new CoreConfig.MysqlSection();
|
||||||
|
if (parsed.xp == null) parsed.xp = new CoreConfig.XpSection();
|
||||||
return applyEnvOverrides(parsed);
|
return applyEnvOverrides(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public final class CoreConfig {
|
|||||||
public MetricsSection metrics = new MetricsSection();
|
public MetricsSection metrics = new MetricsSection();
|
||||||
public RpcSection rpc = new RpcSection();
|
public RpcSection rpc = new RpcSection();
|
||||||
public MysqlSection mysql = new MysqlSection();
|
public MysqlSection mysql = new MysqlSection();
|
||||||
|
public XpSection xp = new XpSection();
|
||||||
|
|
||||||
public static final class ServerSection {
|
public static final class ServerSection {
|
||||||
@Nullable
|
@Nullable
|
||||||
@@ -70,6 +71,26 @@ public final class CoreConfig {
|
|||||||
public long timeout_ms = 5000L;
|
public long timeout_ms = 5000L;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static final class XpSection {
|
||||||
|
/**
|
||||||
|
* Enables the global network XP service. Requires mysql.enabled = true.
|
||||||
|
*/
|
||||||
|
public boolean enabled = true;
|
||||||
|
/**
|
||||||
|
* Table name for per-player XP totals.
|
||||||
|
*/
|
||||||
|
public String table = "network_xp";
|
||||||
|
/**
|
||||||
|
* Bus channel for level-up events.
|
||||||
|
*/
|
||||||
|
public String level_up_channel = "network.xp.levelup";
|
||||||
|
/**
|
||||||
|
* Triangular level curve base. Reaching level L requires base * L*(L+1)/2 total XP.
|
||||||
|
* With base=100: L1=100, L2=300, L3=600, L5=1500, L10=5500, L20=21000.
|
||||||
|
*/
|
||||||
|
public long level_curve_base = 100L;
|
||||||
|
}
|
||||||
|
|
||||||
public static final class MysqlSection {
|
public static final class MysqlSection {
|
||||||
public boolean enabled = false;
|
public boolean enabled = false;
|
||||||
public String host = "127.0.0.1";
|
public String host = "127.0.0.1";
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package net.kewwbec.networkcore.xp;
|
||||||
|
|
||||||
|
import com.hypixel.hytale.logger.HytaleLogger;
|
||||||
|
import net.kewwbec.networkcore.api.MessageBus;
|
||||||
|
import net.kewwbec.networkcore.api.xp.LevelCurve;
|
||||||
|
import net.kewwbec.networkcore.api.xp.LevelInfo;
|
||||||
|
import net.kewwbec.networkcore.api.xp.NetworkXp;
|
||||||
|
import net.kewwbec.networkcore.api.xp.PlayerLevelUpPayload;
|
||||||
|
import net.kewwbec.networkcore.config.CoreConfig;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
|
||||||
|
public final class NetworkXpServiceImpl implements NetworkXp {
|
||||||
|
|
||||||
|
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||||
|
|
||||||
|
private final XpRepository repo;
|
||||||
|
private final LevelCurve curve;
|
||||||
|
private final MessageBus bus;
|
||||||
|
private final CoreConfig.XpSection cfg;
|
||||||
|
private final String selfServerId;
|
||||||
|
|
||||||
|
public NetworkXpServiceImpl(@Nonnull XpRepository repo,
|
||||||
|
@Nonnull LevelCurve curve,
|
||||||
|
@Nonnull MessageBus bus,
|
||||||
|
@Nonnull CoreConfig.XpSection cfg,
|
||||||
|
@Nonnull String selfServerId) {
|
||||||
|
this.repo = repo;
|
||||||
|
this.curve = curve;
|
||||||
|
this.bus = bus;
|
||||||
|
this.cfg = cfg;
|
||||||
|
this.selfServerId = selfServerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Nonnull
|
||||||
|
public LevelCurve curve() {
|
||||||
|
return curve;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getTotal(@Nonnull UUID uuid) {
|
||||||
|
try { return repo.get(uuid); }
|
||||||
|
catch (SQLException e) {
|
||||||
|
LOGGER.at(Level.WARNING).log("XP get failed for %s: %s", uuid, e.getMessage());
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getLevel(@Nonnull UUID uuid) {
|
||||||
|
return curve.levelForTotal(getTotal(uuid));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Nonnull
|
||||||
|
public LevelInfo getInfo(@Nonnull UUID uuid) {
|
||||||
|
return curve.info(getTotal(uuid));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long add(@Nonnull UUID uuid, long delta, @Nullable String source) {
|
||||||
|
if (delta == 0L) return getTotal(uuid);
|
||||||
|
try {
|
||||||
|
long before = repo.get(uuid);
|
||||||
|
int oldLevel = curve.levelForTotal(before);
|
||||||
|
long after = repo.add(uuid, delta);
|
||||||
|
int newLevel = curve.levelForTotal(after);
|
||||||
|
if (oldLevel != newLevel) publishLevelUp(uuid, oldLevel, newLevel, after, source);
|
||||||
|
return after;
|
||||||
|
} catch (SQLException e) {
|
||||||
|
LOGGER.at(Level.WARNING).log("XP add failed for %s: %s", uuid, e.getMessage());
|
||||||
|
return getTotal(uuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long set(@Nonnull UUID uuid, long total, @Nullable String source) {
|
||||||
|
try {
|
||||||
|
long before = repo.get(uuid);
|
||||||
|
int oldLevel = curve.levelForTotal(before);
|
||||||
|
long after = repo.set(uuid, total);
|
||||||
|
int newLevel = curve.levelForTotal(after);
|
||||||
|
if (oldLevel != newLevel) publishLevelUp(uuid, oldLevel, newLevel, after, source);
|
||||||
|
return after;
|
||||||
|
} catch (SQLException e) {
|
||||||
|
LOGGER.at(Level.WARNING).log("XP set failed for %s: %s", uuid, e.getMessage());
|
||||||
|
return getTotal(uuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void publishLevelUp(@Nonnull UUID uuid, int oldLevel, int newLevel, long total, @Nullable String source) {
|
||||||
|
bus.publish(cfg.level_up_channel, new PlayerLevelUpPayload(
|
||||||
|
uuid.toString(), oldLevel, newLevel, total, source, selfServerId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package net.kewwbec.networkcore.xp;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public final class XpRepository {
|
||||||
|
|
||||||
|
private final DataSource ds;
|
||||||
|
private final String table;
|
||||||
|
|
||||||
|
public XpRepository(@Nonnull DataSource ds, @Nonnull String table) {
|
||||||
|
this.ds = ds;
|
||||||
|
this.table = table;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long get(@Nonnull UUID uuid) throws SQLException {
|
||||||
|
String sql = "SELECT xp_total FROM " + table + " WHERE uuid = ?";
|
||||||
|
try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement(sql)) {
|
||||||
|
p.setString(1, uuid.toString());
|
||||||
|
try (ResultSet rs = p.executeQuery()) {
|
||||||
|
return rs.next() ? rs.getLong(1) : 0L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically adds {@code delta} (can be negative; result clamped to >= 0) and returns the new
|
||||||
|
* total.
|
||||||
|
*/
|
||||||
|
public long add(@Nonnull UUID uuid, long delta) throws SQLException {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
String insert = "INSERT INTO " + table + " (uuid, xp_total, updated_at) VALUES (?, GREATEST(0, ?), ?) " +
|
||||||
|
"ON DUPLICATE KEY UPDATE xp_total = GREATEST(0, xp_total + VALUES(xp_total)), updated_at = VALUES(updated_at)";
|
||||||
|
try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement(insert)) {
|
||||||
|
p.setString(1, uuid.toString());
|
||||||
|
p.setLong(2, delta);
|
||||||
|
p.setLong(3, now);
|
||||||
|
p.executeUpdate();
|
||||||
|
}
|
||||||
|
return get(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public long set(@Nonnull UUID uuid, long total) throws SQLException {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
long clamped = Math.max(0L, total);
|
||||||
|
String sql = "INSERT INTO " + table + " (uuid, xp_total, updated_at) VALUES (?, ?, ?) " +
|
||||||
|
"ON DUPLICATE KEY UPDATE xp_total = VALUES(xp_total), updated_at = VALUES(updated_at)";
|
||||||
|
try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement(sql)) {
|
||||||
|
p.setString(1, uuid.toString());
|
||||||
|
p.setLong(2, clamped);
|
||||||
|
p.setLong(3, now);
|
||||||
|
p.executeUpdate();
|
||||||
|
}
|
||||||
|
return clamped;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package net.kewwbec.networkcore.xp;
|
||||||
|
|
||||||
|
import com.hypixel.hytale.logger.HytaleLogger;
|
||||||
|
import net.kewwbec.networkcore.config.CoreConfig;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
|
||||||
|
public final class XpSchemaBootstrap {
|
||||||
|
|
||||||
|
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||||
|
|
||||||
|
private XpSchemaBootstrap() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void apply(@Nonnull DataSource ds, @Nonnull CoreConfig.XpSection cfg) throws SQLException {
|
||||||
|
try (Connection c = ds.getConnection(); Statement s = c.createStatement()) {
|
||||||
|
s.executeUpdate("""
|
||||||
|
CREATE TABLE IF NOT EXISTS %s (
|
||||||
|
uuid CHAR(36) NOT NULL,
|
||||||
|
xp_total BIGINT NOT NULL DEFAULT 0,
|
||||||
|
updated_at BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (uuid),
|
||||||
|
KEY idx_xp_total (xp_total)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
""".formatted(cfg.table));
|
||||||
|
LOGGER.at(Level.INFO).log("XP schema bootstrap complete (%s)", cfg.table);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user