bridge for plugins

This commit is contained in:
2026-05-29 13:28:07 -04:00
parent 45934b0706
commit 52945bdc98
16 changed files with 632 additions and 0 deletions
@@ -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 &lt;player&gt; &lt;amount&gt; | set &lt;player&gt; &lt;total&gt;]
*
* 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);
}
}
}