diff --git a/src/main/java/net/kewwbec/networkcore/NetworkCore.java b/src/main/java/net/kewwbec/networkcore/NetworkCore.java index d13b211..7082e73 100644 --- a/src/main/java/net/kewwbec/networkcore/NetworkCore.java +++ b/src/main/java/net/kewwbec/networkcore/NetworkCore.java @@ -22,6 +22,7 @@ import net.kewwbec.networkcore.command.network.HubCommand; import net.kewwbec.networkcore.command.network.LockServerCommand; import net.kewwbec.networkcore.command.network.SendAllCommand; 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.CoreConfig; 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.RefRequestService; 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.registry.RedisServerRegistry; import net.kewwbec.networkcore.rpc.RpcClientImpl; @@ -44,6 +50,7 @@ import net.kewwbec.networkcore.rpc.RpcClientImpl; import javax.annotation.Nonnull; import javax.annotation.Nullable; import java.io.IOException; +import java.sql.SQLException; import java.util.logging.Level; 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 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"); } @@ -252,6 +277,23 @@ public final class NetworkCore extends JavaPlugin { public DatabaseService getDatabase() { return services.get(DatabaseService.class); } @Nullable 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 findService(@Nonnull Class type) { + return services.get(type); + } @Nonnull public CoreConfig getConfigSnapshot() { diff --git a/src/main/java/net/kewwbec/networkcore/NetworkCorePermissions.java b/src/main/java/net/kewwbec/networkcore/NetworkCorePermissions.java index e9723e7..c53f63e 100644 --- a/src/main/java/net/kewwbec/networkcore/NetworkCorePermissions.java +++ b/src/main/java/net/kewwbec/networkcore/NetworkCorePermissions.java @@ -14,6 +14,7 @@ public final class NetworkCorePermissions { public static final String SENDALL = ROOT + ".sendall"; public static final String LOCKSERVER = ROOT + ".lockserver"; public static final String LOCKSERVER_BYPASS = ROOT + ".lockserver.bypass"; + public static final String XP_ADMIN = ROOT + ".xp.admin"; private NetworkCorePermissions() { } diff --git a/src/main/java/net/kewwbec/networkcore/api/guilds/GuildInfo.java b/src/main/java/net/kewwbec/networkcore/api/guilds/GuildInfo.java new file mode 100644 index 0000000..f73240d --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/api/guilds/GuildInfo.java @@ -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 +) { +} diff --git a/src/main/java/net/kewwbec/networkcore/api/guilds/GuildLookup.java b/src/main/java/net/kewwbec/networkcore/api/guilds/GuildLookup.java new file mode 100644 index 0000000..776c042 --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/api/guilds/GuildLookup.java @@ -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); +} diff --git a/src/main/java/net/kewwbec/networkcore/api/ranks/RankInfo.java b/src/main/java/net/kewwbec/networkcore/api/ranks/RankInfo.java new file mode 100644 index 0000000..66e5f0c --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/api/ranks/RankInfo.java @@ -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 +) { +} diff --git a/src/main/java/net/kewwbec/networkcore/api/ranks/RankLookup.java b/src/main/java/net/kewwbec/networkcore/api/ranks/RankLookup.java new file mode 100644 index 0000000..346bf44 --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/api/ranks/RankLookup.java @@ -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); +} diff --git a/src/main/java/net/kewwbec/networkcore/api/xp/LevelCurve.java b/src/main/java/net/kewwbec/networkcore/api/xp/LevelCurve.java new file mode 100644 index 0000000..05713c2 --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/api/xp/LevelCurve.java @@ -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)); + } +} diff --git a/src/main/java/net/kewwbec/networkcore/api/xp/LevelInfo.java b/src/main/java/net/kewwbec/networkcore/api/xp/LevelInfo.java new file mode 100644 index 0000000..b092f32 --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/api/xp/LevelInfo.java @@ -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 +) { +} diff --git a/src/main/java/net/kewwbec/networkcore/api/xp/NetworkXp.java b/src/main/java/net/kewwbec/networkcore/api/xp/NetworkXp.java new file mode 100644 index 0000000..9c3116a --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/api/xp/NetworkXp.java @@ -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); +} diff --git a/src/main/java/net/kewwbec/networkcore/api/xp/PlayerLevelUpPayload.java b/src/main/java/net/kewwbec/networkcore/api/xp/PlayerLevelUpPayload.java new file mode 100644 index 0000000..542b197 --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/api/xp/PlayerLevelUpPayload.java @@ -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; + } +} diff --git a/src/main/java/net/kewwbec/networkcore/command/network/XpCommand.java b/src/main/java/net/kewwbec/networkcore/command/network/XpCommand.java new file mode 100644 index 0000000..df0af9d --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/command/network/XpCommand.java @@ -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 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 store, @Nonnull Ref 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 nameArg = withRequiredArg("player", "Player name", ArgTypes.STRING); + private final RequiredArg 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 store, @Nonnull Ref 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 ").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 nameArg = withRequiredArg("player", "Player name", ArgTypes.STRING); + private final RequiredArg 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 store, @Nonnull Ref 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 ").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)); + } + } +} diff --git a/src/main/java/net/kewwbec/networkcore/config/ConfigLoader.java b/src/main/java/net/kewwbec/networkcore/config/ConfigLoader.java index 210f65c..faefd7d 100644 --- a/src/main/java/net/kewwbec/networkcore/config/ConfigLoader.java +++ b/src/main/java/net/kewwbec/networkcore/config/ConfigLoader.java @@ -36,6 +36,7 @@ public final class ConfigLoader { if (parsed.metrics == null) parsed.metrics = new CoreConfig.MetricsSection(); if (parsed.rpc == null) parsed.rpc = new CoreConfig.RpcSection(); if (parsed.mysql == null) parsed.mysql = new CoreConfig.MysqlSection(); + if (parsed.xp == null) parsed.xp = new CoreConfig.XpSection(); return applyEnvOverrides(parsed); } diff --git a/src/main/java/net/kewwbec/networkcore/config/CoreConfig.java b/src/main/java/net/kewwbec/networkcore/config/CoreConfig.java index f4fe45c..fe939d5 100644 --- a/src/main/java/net/kewwbec/networkcore/config/CoreConfig.java +++ b/src/main/java/net/kewwbec/networkcore/config/CoreConfig.java @@ -10,6 +10,7 @@ public final class CoreConfig { public MetricsSection metrics = new MetricsSection(); public RpcSection rpc = new RpcSection(); public MysqlSection mysql = new MysqlSection(); + public XpSection xp = new XpSection(); public static final class ServerSection { @Nullable @@ -70,6 +71,26 @@ public final class CoreConfig { 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 boolean enabled = false; public String host = "127.0.0.1"; diff --git a/src/main/java/net/kewwbec/networkcore/xp/NetworkXpServiceImpl.java b/src/main/java/net/kewwbec/networkcore/xp/NetworkXpServiceImpl.java new file mode 100644 index 0000000..27a03fa --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/xp/NetworkXpServiceImpl.java @@ -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)); + } +} diff --git a/src/main/java/net/kewwbec/networkcore/xp/XpRepository.java b/src/main/java/net/kewwbec/networkcore/xp/XpRepository.java new file mode 100644 index 0000000..c0a76fb --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/xp/XpRepository.java @@ -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; + } +} diff --git a/src/main/java/net/kewwbec/networkcore/xp/XpSchemaBootstrap.java b/src/main/java/net/kewwbec/networkcore/xp/XpSchemaBootstrap.java new file mode 100644 index 0000000..963ae0f --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/xp/XpSchemaBootstrap.java @@ -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); + } + } +}