diff --git a/pom.xml b/pom.xml
index 6bed57e..a637f15 100644
--- a/pom.xml
+++ b/pom.xml
@@ -10,7 +10,7 @@
jar
Hub
- Lobby/hub behavior: protection, welcome message, spawn-on-join
+ Lobby/hub behavior: protection, welcome message, spawn-on-join, daily rewards
21
@@ -18,6 +18,7 @@
UTF-8
${user.home}/AppData/Roaming/Hytale/install/release/package/game/latest/Server/HytaleServer.jar
${project.basedir}/../Core/target/NetworkCore-0.1.0.jar
+ ${project.basedir}/../CoreUI/target/CoreUI-0.1.0.jar
@@ -37,6 +38,14 @@
${networkcore.jar}
+
+ net.kewwbec
+ coreui
+ 0.1.0
+ system
+ ${coreui.jar}
+
+
com.google.code.findbugs
jsr305
@@ -47,5 +56,43 @@
${project.artifactId}-${project.version}
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+ 3.5.1
+
+
+ package
+
+ shade
+
+
+ false
+ false
+
+
+ com.hypixel.hytale:*
+ net.kewwbec:networkcore
+ net.kewwbec:coreui
+ com.google.code.findbugs:*
+
+
+
+
+ *:*
+
+ META-INF/*.SF
+ META-INF/*.DSA
+ META-INF/*.RSA
+ module-info.class
+
+
+
+
+
+
+
+
diff --git a/src/main/java/net/kewwbec/hub/HubPermissionsNodes.java b/src/main/java/net/kewwbec/hub/HubPermissionsNodes.java
index 6659204..2c812a2 100644
--- a/src/main/java/net/kewwbec/hub/HubPermissionsNodes.java
+++ b/src/main/java/net/kewwbec/hub/HubPermissionsNodes.java
@@ -4,6 +4,9 @@ public final class HubPermissionsNodes {
public static final String ROOT = "networkhub";
public static final String BUILD = ROOT + ".build";
+ public static final String REWARDS_USE = ROOT + ".rewards.use";
+ public static final String KOTH_ADMIN = ROOT + ".koth.admin";
+ public static final String UI_ADMIN = ROOT + ".ui.admin";
private HubPermissionsNodes() {
}
diff --git a/src/main/java/net/kewwbec/hub/HubPlugin.java b/src/main/java/net/kewwbec/hub/HubPlugin.java
index ee6add8..afc54f0 100644
--- a/src/main/java/net/kewwbec/hub/HubPlugin.java
+++ b/src/main/java/net/kewwbec/hub/HubPlugin.java
@@ -1,6 +1,10 @@
package net.kewwbec.hub;
+import com.hypixel.hytale.builtin.triggervolumes.event.TriggerVolumeEvent;
+import com.hypixel.hytale.component.Ref;
+import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.logger.HytaleLogger;
+import com.hypixel.hytale.server.core.HytaleServer;
import com.hypixel.hytale.server.core.event.events.player.AddPlayerToWorldEvent;
import com.hypixel.hytale.server.core.event.events.player.PlayerDisconnectEvent;
import com.hypixel.hytale.server.core.event.events.player.PlayerReadyEvent;
@@ -8,21 +12,46 @@ import com.hypixel.hytale.server.core.event.events.player.RemovedPlayerFromWorld
import com.hypixel.hytale.server.core.plugin.JavaPlugin;
import com.hypixel.hytale.server.core.plugin.JavaPluginInit;
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.events.AllWorldsLoadedEvent;
+import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
+import net.kewwbec.coreui.api.AvatarRenderType;
+import net.kewwbec.coreui.api.AvatarRequest;
+import net.kewwbec.coreui.api.Avatars;
import net.kewwbec.hub.command.BuildCommand;
+import net.kewwbec.hub.command.MenuCommand;
+import net.kewwbec.hub.command.RewardsCommand;
+import net.kewwbec.hub.command.UnhideHubUICommand;
import net.kewwbec.hub.config.HubConfig;
import net.kewwbec.hub.config.HubConfigLoader;
+import net.kewwbec.hub.db.DailyRewardRepository;
+import net.kewwbec.hub.db.HubSchemaBootstrap;
+import net.kewwbec.hub.koth.KothCommand;
+import net.kewwbec.hub.koth.KothListener;
+import net.kewwbec.hub.koth.KothService;
+import net.kewwbec.hub.listener.HubHudVisibility;
+import net.kewwbec.hub.listener.HubInventoryListener;
import net.kewwbec.hub.listener.HubJoinListener;
import net.kewwbec.hub.listener.HubMessageListener;
-import net.kewwbec.hub.protection.CancelBreakBlockSystem;
-import net.kewwbec.hub.protection.CancelDamageBlockSystem;
-import net.kewwbec.hub.protection.CancelPlaceBlockSystem;
-import net.kewwbec.hub.protection.HubWorldFilter;
+import net.kewwbec.hub.listener.MenuItemActivateSystem;
+import net.kewwbec.hub.protection.*;
import net.kewwbec.hub.service.BuildModeService;
+import net.kewwbec.hub.service.DailyRewardService;
import net.kewwbec.hub.service.HubWorldService;
+import net.kewwbec.hub.ui.MenuHintHud;
+import net.kewwbec.hub.ui.NetworkXpHud;
+import net.kewwbec.networkcore.NetworkCore;
+import net.kewwbec.networkcore.api.DatabaseService;
+import net.kewwbec.networkcore.api.xp.NetworkXp;
import javax.annotation.Nonnull;
import java.io.IOException;
+import java.sql.SQLException;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
public final class HubPlugin extends JavaPlugin {
@@ -34,6 +63,14 @@ public final class HubPlugin extends JavaPlugin {
private BuildModeService buildMode;
private HubWorldService worldService;
+ private final Map xpHuds = new ConcurrentHashMap<>();
+ private final Map menuHintHuds = new ConcurrentHashMap<>();
+ @javax.annotation.Nullable private ScheduledFuture> xpHudTask;
+
+ @javax.annotation.Nullable private KothService koth;
+ @javax.annotation.Nullable private MenuItemActivateSystem menuItemSystem;
+ @javax.annotation.Nullable private HubHudVisibility hudVisibility;
+
public HubPlugin(@Nonnull JavaPluginInit init) {
super(init);
}
@@ -56,15 +93,29 @@ public final class HubPlugin extends JavaPlugin {
this.worldService = new HubWorldService(config);
HubJoinListener joinListener = new HubJoinListener(config, filter);
HubMessageListener messageListener = new HubMessageListener(config, filter);
+ this.hudVisibility = new HubHudVisibility(config.inventory.hidden_hud_components);
+ hudVisibility.setChangeListener(this::onHudVisibilityChange);
+ HubInventoryListener inventoryListener = new HubInventoryListener(config.inventory, filter, hudVisibility);
getEventRegistry().registerGlobal(AllWorldsLoadedEvent.class, e -> worldService.applyToConfiguredWorlds());
worldService.applyToConfiguredWorlds();
getEventRegistry().registerGlobal(PlayerReadyEvent.class, joinListener::onReady);
+ getEventRegistry().registerGlobal(PlayerReadyEvent.class, inventoryListener::onReady);
+ getEventRegistry().registerGlobal(PlayerReadyEvent.class, this::prefetchAvatar);
+ getEventRegistry().registerGlobal(PlayerReadyEvent.class, this::showXpHud);
+
getEventRegistry().registerGlobal(AddPlayerToWorldEvent.class, messageListener::onAdd);
getEventRegistry().registerGlobal(RemovedPlayerFromWorldEvent.class, messageListener::onRemove);
getEventRegistry().registerGlobal(PlayerDisconnectEvent.class, this::onDisconnect);
+ if (config.koth.enabled) {
+ this.koth = new KothService(config.koth, NetworkCore.getInstance().getNetworkXp(), hudVisibility);
+ }
+
+ this.menuItemSystem = new MenuItemActivateSystem(config.inventory, filter, buildMode, hudVisibility, koth);
+ getEntityStoreRegistry().registerSystem(menuItemSystem);
+
if (config.protection.disable_block_break) {
getEntityStoreRegistry().registerSystem(new CancelBreakBlockSystem(filter, buildMode));
}
@@ -74,19 +125,161 @@ public final class HubPlugin extends JavaPlugin {
if (config.protection.disable_block_damage) {
getEntityStoreRegistry().registerSystem(new CancelDamageBlockSystem(filter, buildMode));
}
+ if (config.protection.disable_damage) {
+ getEntityStoreRegistry().registerSystem(new CancelDamageSystem(filter, koth));
+ }
+ if (config.protection.disable_stamina_drain) {
+ getEntityStoreRegistry().registerSystem(new RefillStaminaSystem(filter));
+ }
+ if (config.protection.disable_item_drop) {
+ getEntityStoreRegistry().registerSystem(new CancelDropItemSystem(filter));
+ }
- getCommandRegistry().registerCommand(new BuildCommand(buildMode));
+ getCommandRegistry().registerCommand(new BuildCommand(buildMode, hudVisibility, koth));
+ getCommandRegistry().registerCommand(new MenuCommand());
+ getCommandRegistry().registerCommand(new UnhideHubUICommand(hudVisibility, filter, buildMode, koth));
+
+ if (config.rewards.enabled) {
+ NetworkCore core = NetworkCore.getInstance();
+ DatabaseService db = core.getDatabase();
+ if (db == null || !db.isHealthy()) {
+ LOGGER.at(Level.WARNING).log("Hub rewards require NetworkCore MySQL; skipping (rewards.enabled=true but mysql is unavailable)");
+ } else {
+ try {
+ HubSchemaBootstrap.apply(db.getDataSource(), config.rewards);
+ DailyRewardRepository rewardRepo = new DailyRewardRepository(db.getDataSource(), config.rewards.table);
+ NetworkXp xp = core.getNetworkXp();
+ if (xp == null) {
+ LOGGER.at(Level.WARNING).log("NetworkXp service unavailable; rewards will record claims but not award XP");
+ }
+ DailyRewardService rewards = new DailyRewardService(rewardRepo, xp, config.rewards);
+ getCommandRegistry().registerCommand(new RewardsCommand(rewards));
+ LOGGER.at(Level.INFO).log("Hub rewards ready (table=%s, %d XP per claim)", config.rewards.table, config.rewards.xp_per_claim);
+ } catch (SQLException e) {
+ ((HytaleLogger.Api) LOGGER.at(Level.SEVERE).withCause(e)).log("Failed to bootstrap Hub rewards schema; rewards disabled");
+ }
+ }
+ }
+
+ xpHudTask = HytaleServer.SCHEDULED_EXECUTOR.scheduleAtFixedRate(
+ this::refreshAllXpHuds, 10L, 10L, TimeUnit.SECONDS);
+
+ if (koth != null) {
+ koth.start();
+ KothListener kothListener = new KothListener(koth, config.koth);
+ getEventRegistry().registerGlobal(TriggerVolumeEvent.class, kothListener::onTrigger);
+ getCommandRegistry().registerCommand(new KothCommand(koth));
+ LOGGER.at(Level.INFO).log("KOTH ready (arena_tag=%s, capture_tag=%s, round=%ds, intermission=%ds, xp=%d/%d/%d (+%d), kb=%.1fx)",
+ config.koth.arena_tag, config.koth.capture_tag, config.koth.round_seconds,
+ config.koth.intermission_seconds,
+ config.koth.xp_first, config.koth.xp_second, config.koth.xp_third, config.koth.xp_participation,
+ config.koth.knockback_multiplier);
+ }
LOGGER.at(Level.INFO).log("Hub started");
}
private void onDisconnect(@Nonnull PlayerDisconnectEvent event) {
PlayerRef ref = event.getPlayerRef();
- if (ref != null) buildMode.clear(ref.getUuid());
+ if (ref == null) return;
+ buildMode.clear(ref.getUuid());
+ xpHuds.remove(ref.getUuid());
+ menuHintHuds.remove(ref.getUuid());
+ if (koth != null) koth.onDisconnect(ref);
+ if (menuItemSystem != null) menuItemSystem.clearPlayer(ref.getUuid());
+ if (hudVisibility != null) hudVisibility.clearPlayer(ref.getUuid());
+ }
+
+ private void onHudVisibilityChange(@Nonnull PlayerRef ref) {
+ Ref r = ref.getReference();
+ if (r == null || !r.isValid()) return;
+ World world = r.getStore().getExternalData().getWorld();
+ if (world == null) return;
+ UUID uuid = ref.getUuid();
+ boolean isHidden = hudVisibility != null && hudVisibility.isHidden(uuid);
+
+ NetworkXpHud xpHud = xpHuds.get(uuid);
+ if (xpHud != null) {
+ world.execute(() -> { try { xpHud.refresh(); } catch (RuntimeException ignored) {} });
+ }
+
+ MenuHintHud hint = menuHintHuds.get(uuid);
+ if (hint != null) {
+ world.execute(() -> {
+ try {
+ if (isHidden) hint.show();
+ else hint.clear();
+ } catch (RuntimeException ignored) {}
+ });
+ }
+ }
+
+ private void showXpHud(@Nonnull PlayerReadyEvent event) {
+ Ref ref = event.getPlayerRef();
+ if (ref == null || !ref.isValid()) return;
+ Store store = ref.getStore();
+ PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType());
+ if (playerRef == null) return;
+ World world = store.getExternalData().getWorld();
+ if (world == null || !filter.isHub(world)) return;
+ world.execute(() -> {
+ NetworkXp xp = NetworkCore.getInstance().getNetworkXp();
+ NetworkXpHud hud = new NetworkXpHud(playerRef, xp, hudVisibility);
+ xpHuds.put(playerRef.getUuid(), hud);
+ hud.show();
+
+ MenuHintHud hint = new MenuHintHud(playerRef);
+ menuHintHuds.put(playerRef.getUuid(), hint);
+ hint.show();
+ });
+ }
+
+ private void refreshAllXpHuds() {
+ for (NetworkXpHud hud : xpHuds.values()) {
+ PlayerRef pr = hud.getPlayerRef();
+ if (!pr.isValid()) continue;
+ Ref ref = pr.getReference();
+ if (ref == null || !ref.isValid()) continue;
+ World world = ref.getStore().getExternalData().getWorld();
+ if (world == null) continue;
+ world.execute(() -> {
+ try { hud.refresh(); } catch (RuntimeException ignored) {}
+ });
+ }
+ }
+
+ private void prefetchAvatar(@Nonnull PlayerReadyEvent event) {
+ Ref ref = event.getPlayerRef();
+ if (ref == null || !ref.isValid()) return;
+ Store store = ref.getStore();
+ PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType());
+ if (playerRef == null) return;
+ World world = store.getExternalData().getWorld();
+ if (world == null || !filter.isHub(world)) return;
+ Avatars avatars = NetworkCore.getInstance().findService(Avatars.class);
+ if (avatars == null) return;
+ AvatarRequest req = AvatarRequest.forUsername(playerRef.getUsername())
+ .renderType(AvatarRenderType.HEAD).size(256).rotate(25);
+ avatars.installAsync(playerRef, req, world::execute)
+ .exceptionally(t -> {
+ LOGGER.at(Level.WARNING).log("Hub avatar prefetch failed for %s: %s",
+ playerRef.getUsername(), t.getMessage());
+ return null;
+ });
}
@Override
protected void shutdown() {
+ if (xpHudTask != null) {
+ try { xpHudTask.cancel(false); } catch (RuntimeException ignored) {}
+ xpHudTask = null;
+ }
+ xpHuds.clear();
+ menuHintHuds.clear();
+ if (koth != null) {
+ try { koth.stop(); } catch (RuntimeException ignored) {}
+ koth = null;
+ }
LOGGER.at(Level.INFO).log("Hub shutdown complete");
}
}
diff --git a/src/main/java/net/kewwbec/hub/command/BuildCommand.java b/src/main/java/net/kewwbec/hub/command/BuildCommand.java
index 7e5e026..65086ce 100644
--- a/src/main/java/net/kewwbec/hub/command/BuildCommand.java
+++ b/src/main/java/net/kewwbec/hub/command/BuildCommand.java
@@ -9,23 +9,27 @@ 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.hub.HubPermissionsNodes;
+import net.kewwbec.hub.koth.KothService;
+import net.kewwbec.hub.listener.HubHudVisibility;
import net.kewwbec.hub.service.BuildModeService;
import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
import java.awt.Color;
-/**
- * /build - toggle build mode for the caller. Bypasses hub block break/place/damage protection
- * while ON. World-level flags (PvP, fall damage) are not affected; those are server-wide and not
- * per-player. Cleared on disconnect.
- */
public final class BuildCommand extends AbstractPlayerCommand {
private final BuildModeService buildMode;
+ @Nullable private final HubHudVisibility hudVisibility;
+ @Nullable private final KothService koth;
- public BuildCommand(@Nonnull BuildModeService buildMode) {
+ public BuildCommand(@Nonnull BuildModeService buildMode,
+ @Nullable HubHudVisibility hudVisibility,
+ @Nullable KothService koth) {
super("build", "Toggle build mode - bypass hub block protection for yourself");
this.buildMode = buildMode;
+ this.hudVisibility = hudVisibility;
+ this.koth = koth;
requirePermission(HubPermissionsNodes.BUILD);
}
@@ -35,8 +39,13 @@ public final class BuildCommand extends AbstractPlayerCommand {
protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, @Nonnull Ref ref, @Nonnull PlayerRef sender, @Nonnull World world) {
boolean nowOn = buildMode.toggle(sender.getUuid());
if (nowOn) {
- ctx.sendMessage(Message.raw("Build mode ON. You can break, place, and damage blocks in hub worlds. Run /build again to turn off.").color(Color.GREEN));
+ if (hudVisibility != null) hudVisibility.showAll(sender);
+ ctx.sendMessage(Message.raw("Build mode ON. Hub HUD is now visible. Run /build again to turn off.").color(Color.GREEN));
} else {
+ if (hudVisibility != null) {
+ boolean stayUnhidden = koth != null && koth.isInActiveArena(sender.getUuid());
+ if (!stayUnhidden) hudVisibility.applyHidden(sender);
+ }
ctx.sendMessage(Message.raw("Build mode OFF. Hub protection is back to normal for you.").color(Color.YELLOW));
}
}
diff --git a/src/main/java/net/kewwbec/hub/command/MenuCommand.java b/src/main/java/net/kewwbec/hub/command/MenuCommand.java
new file mode 100644
index 0000000..0b88317
--- /dev/null
+++ b/src/main/java/net/kewwbec/hub/command/MenuCommand.java
@@ -0,0 +1,36 @@
+package net.kewwbec.hub.command;
+
+import com.hypixel.hytale.component.Ref;
+import com.hypixel.hytale.component.Store;
+import com.hypixel.hytale.server.core.command.system.CommandContext;
+import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand;
+import com.hypixel.hytale.server.core.entity.entities.Player;
+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.hub.ui.HubMenuPage;
+
+import javax.annotation.Nonnull;
+
+/**
+ * /menu - open the hub main menu (sidebar + 1x3 card grid).
+ */
+public final class MenuCommand extends AbstractPlayerCommand {
+
+ public MenuCommand() {
+ super("menu", "Open the hub main menu");
+ }
+
+ @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) {
+ Player player = store.getComponent(ref, Player.getComponentType());
+ if (player == null) return;
+ player.getPageManager().openCustomPage(ref, store, new HubMenuPage(sender));
+ }
+}
diff --git a/src/main/java/net/kewwbec/hub/command/RewardsCommand.java b/src/main/java/net/kewwbec/hub/command/RewardsCommand.java
new file mode 100644
index 0000000..a7f6139
--- /dev/null
+++ b/src/main/java/net/kewwbec/hub/command/RewardsCommand.java
@@ -0,0 +1,48 @@
+package net.kewwbec.hub.command;
+
+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.basecommands.AbstractPlayerCommand;
+import com.hypixel.hytale.server.core.entity.entities.Player;
+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.hub.HubPermissionsNodes;
+import net.kewwbec.hub.service.DailyRewardService;
+import net.kewwbec.hub.ui.DailyRewardsPage;
+
+import javax.annotation.Nonnull;
+import java.awt.Color;
+
+/**
+ * /rewards - opens the daily rewards page. Functions as the testing entry point until an NPC
+ * with an OpenCustomUIInteraction is wired up.
+ */
+public final class RewardsCommand extends AbstractPlayerCommand {
+
+ private final DailyRewardService rewards;
+
+ public RewardsCommand(@Nonnull DailyRewardService rewards) {
+ super("rewards", "Open the daily rewards menu");
+ this.rewards = rewards;
+ requirePermission(HubPermissionsNodes.REWARDS_USE);
+ }
+
+ @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) {
+ Player player = store.getComponent(ref, Player.getComponentType());
+ if (player == null) {
+ ctx.sendMessage(Message.raw("Could not open rewards page.").color(Color.RED));
+ return;
+ }
+ player.getPageManager().openCustomPage(ref, store, new DailyRewardsPage(sender, rewards));
+ }
+}
diff --git a/src/main/java/net/kewwbec/hub/command/UnhideHubUICommand.java b/src/main/java/net/kewwbec/hub/command/UnhideHubUICommand.java
new file mode 100644
index 0000000..19ddd94
--- /dev/null
+++ b/src/main/java/net/kewwbec/hub/command/UnhideHubUICommand.java
@@ -0,0 +1,86 @@
+package net.kewwbec.hub.command;
+
+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.basecommands.AbstractPlayerCommand;
+import com.hypixel.hytale.server.core.universe.PlayerRef;
+import com.hypixel.hytale.server.core.universe.Universe;
+import com.hypixel.hytale.server.core.universe.world.World;
+import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
+import net.kewwbec.hub.HubPermissionsNodes;
+import net.kewwbec.hub.koth.KothService;
+import net.kewwbec.hub.listener.HubHudVisibility;
+import net.kewwbec.hub.protection.HubWorldFilter;
+import net.kewwbec.hub.service.BuildModeService;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.awt.Color;
+import java.util.Collection;
+
+public final class UnhideHubUICommand extends AbstractPlayerCommand {
+
+ private final HubHudVisibility hudVisibility;
+ private final HubWorldFilter filter;
+ private final BuildModeService buildMode;
+ @Nullable private final KothService koth;
+
+ public UnhideHubUICommand(@Nonnull HubHudVisibility hudVisibility,
+ @Nonnull HubWorldFilter filter,
+ @Nonnull BuildModeService buildMode,
+ @Nullable KothService koth) {
+ super("unhideHubUI", "Globally toggle hub HUD visibility for everyone");
+ this.hudVisibility = hudVisibility;
+ this.filter = filter;
+ this.buildMode = buildMode;
+ this.koth = koth;
+ requirePermission(HubPermissionsNodes.UI_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) {
+ boolean turningOn = !hudVisibility.isGlobalForceShow();
+ hudVisibility.setGlobalForceShow(turningOn);
+
+ Universe u = Universe.get();
+ int affected = 0;
+ if (u != null) {
+ Collection players = u.getPlayers();
+ if (players != null) {
+ for (PlayerRef p : players) {
+ if (p == null) continue;
+ Ref r = p.getReference();
+ if (r == null || !r.isValid()) continue;
+ World w = r.getStore().getExternalData().getWorld();
+ if (w == null || !filter.isHub(w)) continue;
+ if (turningOn) {
+ hudVisibility.showAll(p);
+ } else {
+ boolean keepShown = buildMode.isOn(p.getUuid())
+ || (koth != null && koth.isInActiveArena(p.getUuid()));
+ if (keepShown) {
+ hudVisibility.showAll(p);
+ } else {
+ hudVisibility.applyHidden(p);
+ }
+ }
+ affected++;
+ }
+ }
+ }
+
+ if (turningOn) {
+ ctx.sendMessage(Message.raw("Hub HUD globally UNHIDDEN for " + affected + " player(s). Re-run to revert.").color(Color.GREEN));
+ } else {
+ ctx.sendMessage(Message.raw("Hub HUD restored to default for " + affected + " player(s).").color(Color.YELLOW));
+ }
+ }
+}
diff --git a/src/main/java/net/kewwbec/hub/config/HubConfig.java b/src/main/java/net/kewwbec/hub/config/HubConfig.java
index 399d950..62022e6 100644
--- a/src/main/java/net/kewwbec/hub/config/HubConfig.java
+++ b/src/main/java/net/kewwbec/hub/config/HubConfig.java
@@ -1,6 +1,7 @@
package net.kewwbec.hub.config;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.List;
public final class HubConfig {
@@ -9,6 +10,9 @@ public final class HubConfig {
public ProtectionSection protection = new ProtectionSection();
public MessagesSection messages = new MessagesSection();
public SpawnSection spawn = new SpawnSection();
+ public RewardsSection rewards = new RewardsSection();
+ public KothSection koth = new KothSection();
+ public InventorySection inventory = new InventorySection();
public static final class WorldsSection {
/**
@@ -24,6 +28,12 @@ public final class HubConfig {
public boolean disable_block_place = true;
/** Cancel DamageBlockEvent in hub worlds (mining progress). */
public boolean disable_block_damage = true;
+ /** Cancel every {@code Damage} event in hub worlds (all entities, all sources). */
+ public boolean disable_damage = true;
+ /** Refill player stamina to max every tick in hub worlds (sprint, swing, etc never drain). */
+ public boolean disable_stamina_drain = true;
+ /** Cancel DropItemEvent in hub worlds (Q/throw is suppressed for every player). */
+ public boolean disable_item_drop = true;
/** WorldConfig.isPvpEnabled = false on hub worlds. */
public boolean disable_pvp = true;
/** WorldConfig.isFallDamageEnabled = false on hub worlds. */
@@ -37,18 +47,34 @@ public final class HubConfig {
public static final class MessagesSection {
/**
- * Private welcome shown only to the joining player. {player} is substituted.
- * Empty/null disables.
+ * Private welcome lines shown only to the joining player, one chat message per line.
+ * Each line supports inline color codes and placeholders (see below). Empty list disables.
+ *
+ * Placeholders:
+ * %player% - the joining player's username
+ * %prefix% - their primary rank prefix (empty if no rank or RankLookup unavailable)
+ * %color% - their primary rank color as a RRGGBB code (empty if no color)
+ * %server% - this server's id
+ *
+ * Color codes (anywhere in a line):
+ * RRGGBB - switch to this hex color until the next code or end of line
+ * &r - reset to default color
+ *
+ * Example: "FFAA00Welcome %prefix%%player%! &rType /help to get started."
*/
- public String welcome = "Welcome to the hub, {player}!";
+ public List welcome_lines = new ArrayList<>(Arrays.asList(
+ "FFAA00Welcome to the hub, %prefix%%player%FFAA00!",
+ "AAAAAARun /help any time. Have fun."
+ ));
+
/**
- * Replaces Hytale's default join broadcast on hub worlds. {player} is substituted.
- * Empty/null suppresses the join broadcast entirely.
+ * Replaces Hytale's default join broadcast on hub worlds. Supports the same placeholders
+ * and color codes as welcome_lines. Empty/null suppresses the broadcast entirely.
*/
- public String join_announcement = "{player} joined the hub.";
+ public String join_announcement = "7FF55+ %prefix%%player% 7FF55joined the hub.";
+
/**
- * Replaces Hytale's default leave broadcast on hub worlds. {player} is substituted.
- * Empty/null suppresses the leave broadcast entirely.
+ * Replaces Hytale's default leave broadcast on hub worlds. Empty/null suppresses it.
*/
public String leave_announcement = "";
}
@@ -59,4 +85,41 @@ public final class HubConfig {
*/
public boolean teleport_on_join = true;
}
+
+ public static final class InventorySection {
+ public boolean clear_on_join = true;
+ public boolean open_menu_on_slot_switch = true;
+ public int resting_slot = 6;
+ public int menu_trigger_slot = 0;
+ public List hidden_hud_components = new ArrayList<>(Arrays.asList("Hotbar", "InputBindings"));
+ }
+
+ public static final class KothSection {
+ public boolean enabled = true;
+ public String arena_tag = "koth_arena";
+ public String capture_tag = "koth_capture";
+ public long round_seconds = 300L;
+ public long intermission_seconds = 300L;
+ public long xp_first = 100L;
+ public long xp_second = 60L;
+ public long xp_third = 30L;
+ public long xp_participation = 10L;
+ public String sword_item_id = "Weapon_Sword_Wood";
+ public int sword_slot = 6;
+ public double knockback_multiplier = 4.0;
+ public boolean debug_chat = true;
+ }
+
+ public static final class RewardsSection {
+ /** Enable the daily-rewards feature. Requires NetworkCore MySQL + NetworkXp. */
+ public boolean enabled = true;
+ /** Table name for per-player reward state. */
+ public String table = "hub_daily_rewards";
+ /** Network XP awarded per claim. */
+ public long xp_per_claim = 10L;
+ /** Cooldown between claims, in seconds. Default 24h. */
+ public long cooldown_seconds = 86400L;
+ /** If a player claims within this many seconds AFTER cooldown, streak continues. */
+ public long streak_grace_seconds = 86400L;
+ }
}
diff --git a/src/main/java/net/kewwbec/hub/config/HubConfigLoader.java b/src/main/java/net/kewwbec/hub/config/HubConfigLoader.java
index af6a776..d127801 100644
--- a/src/main/java/net/kewwbec/hub/config/HubConfigLoader.java
+++ b/src/main/java/net/kewwbec/hub/config/HubConfigLoader.java
@@ -31,7 +31,9 @@ public final class HubConfigLoader {
if (parsed.worlds == null) parsed.worlds = new HubConfig.WorldsSection();
if (parsed.protection == null) parsed.protection = new HubConfig.ProtectionSection();
if (parsed.messages == null) parsed.messages = new HubConfig.MessagesSection();
+ if (parsed.messages.welcome_lines == null) parsed.messages.welcome_lines = new HubConfig.MessagesSection().welcome_lines;
if (parsed.spawn == null) parsed.spawn = new HubConfig.SpawnSection();
+ if (parsed.rewards == null) parsed.rewards = new HubConfig.RewardsSection();
return parsed;
}
}
diff --git a/src/main/java/net/kewwbec/hub/db/DailyRewardRepository.java b/src/main/java/net/kewwbec/hub/db/DailyRewardRepository.java
new file mode 100644
index 0000000..683b966
--- /dev/null
+++ b/src/main/java/net/kewwbec/hub/db/DailyRewardRepository.java
@@ -0,0 +1,52 @@
+package net.kewwbec.hub.db;
+
+import net.kewwbec.hub.model.DailyReward;
+
+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.Optional;
+import java.util.UUID;
+
+public final class DailyRewardRepository {
+
+ private final DataSource ds;
+ private final String table;
+
+ public DailyRewardRepository(@Nonnull DataSource ds, @Nonnull String table) {
+ this.ds = ds;
+ this.table = table;
+ }
+
+ @Nonnull
+ public Optional find(@Nonnull UUID uuid) throws SQLException {
+ String sql = "SELECT * FROM " + table + " WHERE uuid = ?";
+ try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement(sql)) {
+ p.setString(1, uuid.toString());
+ try (ResultSet rs = p.executeQuery()) {
+ if (rs.next()) {
+ return Optional.of(new DailyReward(
+ UUID.fromString(rs.getString("uuid")),
+ rs.getLong("last_claim_at"),
+ rs.getInt("streak")
+ ));
+ }
+ }
+ }
+ return Optional.empty();
+ }
+
+ public void upsert(@Nonnull UUID uuid, long lastClaimAt, int streak) throws SQLException {
+ String sql = "INSERT INTO " + table + " (uuid, last_claim_at, streak) VALUES (?, ?, ?) " +
+ "ON DUPLICATE KEY UPDATE last_claim_at = VALUES(last_claim_at), streak = VALUES(streak)";
+ try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement(sql)) {
+ p.setString(1, uuid.toString());
+ p.setLong(2, lastClaimAt);
+ p.setInt(3, streak);
+ p.executeUpdate();
+ }
+ }
+}
diff --git a/src/main/java/net/kewwbec/hub/db/HubSchemaBootstrap.java b/src/main/java/net/kewwbec/hub/db/HubSchemaBootstrap.java
new file mode 100644
index 0000000..2ae6897
--- /dev/null
+++ b/src/main/java/net/kewwbec/hub/db/HubSchemaBootstrap.java
@@ -0,0 +1,33 @@
+package net.kewwbec.hub.db;
+
+import com.hypixel.hytale.logger.HytaleLogger;
+import net.kewwbec.hub.config.HubConfig;
+
+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 HubSchemaBootstrap {
+
+ private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
+
+ private HubSchemaBootstrap() {
+ }
+
+ public static void apply(@Nonnull DataSource ds, @Nonnull HubConfig.RewardsSection 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,
+ last_claim_at BIGINT NOT NULL DEFAULT 0,
+ streak INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (uuid)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
+ """.formatted(cfg.table));
+ LOGGER.at(Level.INFO).log("Hub schema bootstrap complete (%s)", cfg.table);
+ }
+ }
+}
diff --git a/src/main/java/net/kewwbec/hub/koth/KothCommand.java b/src/main/java/net/kewwbec/hub/koth/KothCommand.java
new file mode 100644
index 0000000..0357b75
--- /dev/null
+++ b/src/main/java/net/kewwbec/hub/koth/KothCommand.java
@@ -0,0 +1,80 @@
+package net.kewwbec.hub.koth;
+
+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.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.hub.HubPermissionsNodes;
+
+import javax.annotation.Nonnull;
+import java.awt.Color;
+
+public final class KothCommand extends AbstractCommandCollection {
+
+ public KothCommand(@Nonnull KothService service) {
+ super("koth", "KOTH admin commands");
+ addSubCommand(new StartSub(service));
+ addSubCommand(new EndSub(service));
+ }
+
+ @Override protected boolean canGeneratePermission() { return false; }
+
+ public static final class StartSub extends AbstractPlayerCommand {
+
+ private final KothService service;
+
+ public StartSub(@Nonnull KothService service) {
+ super("start", "Force-start a KOTH round");
+ this.service = service;
+ requirePermission(HubPermissionsNodes.KOTH_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) {
+ if (service.getPhase() == KothService.Phase.ACTIVE) {
+ ctx.sendMessage(Message.raw("KOTH is already active.").color(Color.YELLOW));
+ return;
+ }
+ service.forceStart();
+ ctx.sendMessage(Message.raw("KOTH round started.").color(Color.GREEN));
+ }
+ }
+
+ public static final class EndSub extends AbstractPlayerCommand {
+
+ private final KothService service;
+
+ public EndSub(@Nonnull KothService service) {
+ super("end", "Force-end the current KOTH round");
+ this.service = service;
+ requirePermission(HubPermissionsNodes.KOTH_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) {
+ if (service.getPhase() == KothService.Phase.INTERMISSION) {
+ ctx.sendMessage(Message.raw("KOTH is already in intermission.").color(Color.YELLOW));
+ return;
+ }
+ service.forceEnd();
+ ctx.sendMessage(Message.raw("KOTH round ended.").color(Color.GREEN));
+ }
+ }
+}
diff --git a/src/main/java/net/kewwbec/hub/koth/KothHud.java b/src/main/java/net/kewwbec/hub/koth/KothHud.java
new file mode 100644
index 0000000..3a4f8da
--- /dev/null
+++ b/src/main/java/net/kewwbec/hub/koth/KothHud.java
@@ -0,0 +1,95 @@
+package net.kewwbec.hub.koth;
+
+import com.hypixel.hytale.server.core.entity.entities.player.hud.CustomUIHud;
+import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder;
+import com.hypixel.hytale.server.core.universe.PlayerRef;
+import net.kewwbec.hub.koth.KothService.Phase;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.util.List;
+import java.util.UUID;
+
+public final class KothHud extends CustomUIHud {
+
+ private static final String UI_FILE = "KweebecNet_Hub/Hud/KothHud.ui";
+
+ public KothHud(@Nonnull PlayerRef playerRef) {
+ super(playerRef, "KweebecNetKothHud", 0);
+ }
+
+ public void render(@Nullable Snapshot snap) {
+ UICommandBuilder ui = new UICommandBuilder();
+ build(ui);
+ if (snap != null) {
+ String timerText = snap.phase() == Phase.INTERMISSION
+ ? "Next round in " + formatSeconds(snap.secondsRemaining())
+ : "Round ends in " + formatSeconds(snap.secondsRemaining());
+ ui.set("#KothTimer.Text", timerText);
+
+ String kingText;
+ if (snap.phase() == Phase.INTERMISSION) {
+ kingText = "INTERMISSION";
+ } else if (snap.onHill() == 0) {
+ kingText = "Hill is empty";
+ } else if (snap.onHill() == 1) {
+ kingText = "1 on hill";
+ } else {
+ kingText = snap.onHill() + " on hill";
+ }
+ ui.set("#KothKing.Text", kingText);
+
+ renderBoardLine(ui, "#KothBoardLine1", snap.board(), 0, "1. ");
+ renderBoardLine(ui, "#KothBoardLine2", snap.board(), 1, "2. ");
+ renderBoardLine(ui, "#KothBoardLine3", snap.board(), 2, "3. ");
+
+ if (snap.yourRank() == null) {
+ ui.set("#KothYourStats.Text", "");
+ } else {
+ long seconds = snap.yourTimeMs() == null ? 0L : snap.yourTimeMs() / 1000L;
+ ui.set("#KothYourStats.Text", "You: #" + snap.yourRank() + " " + seconds + "s");
+ }
+ }
+ update(true, ui);
+ }
+
+ public void clear() {
+ update(true, new UICommandBuilder());
+ }
+
+ @Override
+ protected void build(@Nonnull UICommandBuilder ui) {
+ ui.append(UI_FILE);
+ }
+
+ private static void renderBoardLine(@Nonnull UICommandBuilder ui,
+ @Nonnull String selector,
+ @Nonnull List board,
+ int idx,
+ @Nonnull String prefix) {
+ if (idx >= board.size()) {
+ ui.set(selector + ".Text", "");
+ return;
+ }
+ BoardEntry e = board.get(idx);
+ ui.set(selector + ".Text", prefix + e.name() + " - " + (e.timeMs() / 1000) + "s");
+ }
+
+ @Nonnull
+ private static String formatSeconds(long s) {
+ if (s <= 0) return "0s";
+ long m = s / 60;
+ long r = s % 60;
+ if (m > 0) return m + "m " + String.format("%02d", r) + "s";
+ return r + "s";
+ }
+
+ public record BoardEntry(@Nonnull UUID uuid, @Nonnull String name, long timeMs) {}
+
+ public record Snapshot(@Nonnull Phase phase,
+ int onHill,
+ @Nonnull List board,
+ @Nullable Integer yourRank,
+ @Nullable Long yourTimeMs,
+ long secondsRemaining) {}
+}
diff --git a/src/main/java/net/kewwbec/hub/koth/KothListener.java b/src/main/java/net/kewwbec/hub/koth/KothListener.java
new file mode 100644
index 0000000..bdb86f4
--- /dev/null
+++ b/src/main/java/net/kewwbec/hub/koth/KothListener.java
@@ -0,0 +1,132 @@
+package net.kewwbec.hub.koth;
+
+import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin;
+import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerEventType;
+import com.hypixel.hytale.builtin.triggervolumes.event.TriggerVolumeEvent;
+import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager;
+import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry;
+import com.hypixel.hytale.component.Ref;
+import com.hypixel.hytale.component.Store;
+import com.hypixel.hytale.logger.HytaleLogger;
+import com.hypixel.hytale.server.core.Message;
+import com.hypixel.hytale.server.core.universe.PlayerRef;
+import com.hypixel.hytale.server.core.universe.Universe;
+import com.hypixel.hytale.server.core.universe.world.World;
+import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
+import net.kewwbec.hub.config.HubConfig;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.awt.Color;
+import java.lang.reflect.Method;
+import java.util.Map;
+import java.util.logging.Level;
+
+public final class KothListener {
+
+ private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
+
+ @Nullable
+ private static final Method GET_MANAGER;
+ static {
+ Method m = null;
+ try {
+ m = TriggerVolumesPlugin.class.getDeclaredMethod("getManager", World.class);
+ m.setAccessible(true);
+ } catch (NoSuchMethodException e) {
+ LOGGER.at(Level.SEVERE).log("KOTH: TriggerVolumesPlugin.getManager(World) not found, KOTH disabled");
+ }
+ GET_MANAGER = m;
+ }
+
+ private final KothService service;
+ private final HubConfig.KothSection cfg;
+
+ public KothListener(@Nonnull KothService service, @Nonnull HubConfig.KothSection cfg) {
+ this.service = service;
+ this.cfg = cfg;
+ }
+
+ public void onTrigger(@Nonnull TriggerVolumeEvent event) {
+ TriggerEventType type = event.getTriggerEventType();
+
+ Ref entityRef = event.getEntityRef();
+ if (entityRef == null || !entityRef.isValid()) {
+ debugLog("KOTH: event " + type + " volume=" + event.getVolumeId() + " no entityRef");
+ return;
+ }
+ Store store = entityRef.getStore();
+ PlayerRef playerRef = store.getComponent(entityRef, PlayerRef.getComponentType());
+ if (playerRef == null) {
+ debugLog("KOTH: event " + type + " volume=" + event.getVolumeId() + " entity is not a player");
+ return;
+ }
+
+ if (type != TriggerEventType.ENTER && type != TriggerEventType.EXIT) {
+ debug(playerRef, "KOTH event " + type + " (ignored) volume=" + event.getVolumeId());
+ return;
+ }
+
+ String volumeId = event.getVolumeId();
+ Map tags = lookupTags(event.getWorldName(), volumeId);
+ boolean isArena = cfg.arena_tag.equals(volumeId) || (tags != null && tags.containsKey(cfg.arena_tag));
+ boolean isCapture = cfg.capture_tag.equals(volumeId) || (tags != null && tags.containsKey(cfg.capture_tag));
+ debug(playerRef, "KOTH " + type + " volume=" + volumeId
+ + " tags=" + (tags == null ? "" : tags.keySet())
+ + " arena=" + isArena + " capture=" + isCapture);
+
+ if (!isArena && !isCapture) return;
+
+ if (type == TriggerEventType.ENTER) {
+ if (isArena) {
+ service.onEnterArena(playerRef);
+ debug(playerRef, "KOTH -> onEnterArena (sword + HUD pushed)");
+ }
+ if (isCapture) {
+ service.onEnterCapture(playerRef);
+ debug(playerRef, "KOTH -> onEnterCapture (you are king)");
+ }
+ } else {
+ if (isCapture) {
+ service.onExitCapture(playerRef);
+ debug(playerRef, "KOTH -> onExitCapture");
+ }
+ if (isArena) {
+ service.onExitArena(playerRef);
+ debug(playerRef, "KOTH -> onExitArena (sword + HUD cleared)");
+ }
+ }
+ }
+
+ @Nullable
+ private Map lookupTags(@Nonnull String worldName, @Nonnull String volumeId) {
+ if (GET_MANAGER == null) return null;
+ Universe u = Universe.get();
+ if (u == null) return null;
+ World w = u.getWorld(worldName);
+ if (w == null) return null;
+ TriggerVolumesPlugin plugin = TriggerVolumesPlugin.get();
+ if (plugin == null) return null;
+ TriggerVolumeManager mgr;
+ try {
+ mgr = (TriggerVolumeManager) GET_MANAGER.invoke(plugin, w);
+ } catch (ReflectiveOperationException e) {
+ ((HytaleLogger.Api) LOGGER.at(Level.WARNING).withCause(e)).log("KOTH: TriggerVolumesPlugin.getManager failed");
+ return null;
+ }
+ if (mgr == null) return null;
+ VolumeEntry entry = mgr.getVolume(volumeId);
+ if (entry == null) return null;
+ return entry.getRawTags();
+ }
+
+ private void debug(@Nonnull PlayerRef ref, @Nonnull String text) {
+ if (!cfg.debug_chat) return;
+ try { ref.sendMessage(Message.raw("[koth-debug] " + text).color(new Color(0x8898B5))); }
+ catch (RuntimeException ignored) {}
+ }
+
+ private void debugLog(@Nonnull String text) {
+ if (cfg.debug_chat) LOGGER.at(Level.INFO).log(text);
+ }
+}
diff --git a/src/main/java/net/kewwbec/hub/koth/KothService.java b/src/main/java/net/kewwbec/hub/koth/KothService.java
new file mode 100644
index 0000000..f0a5f68
--- /dev/null
+++ b/src/main/java/net/kewwbec/hub/koth/KothService.java
@@ -0,0 +1,414 @@
+package net.kewwbec.hub.koth;
+
+import com.hypixel.hytale.component.Ref;
+import com.hypixel.hytale.component.Store;
+import com.hypixel.hytale.logger.HytaleLogger;
+import com.hypixel.hytale.server.core.HytaleServer;
+import com.hypixel.hytale.server.core.Message;
+import com.hypixel.hytale.server.core.entity.entities.Player;
+import com.hypixel.hytale.server.core.inventory.ItemStack;
+import com.hypixel.hytale.server.core.universe.PlayerRef;
+import com.hypixel.hytale.server.core.universe.Universe;
+import com.hypixel.hytale.server.core.universe.world.World;
+import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
+import net.kewwbec.hub.config.HubConfig;
+import net.kewwbec.hub.koth.KothHud.BoardEntry;
+import net.kewwbec.hub.koth.KothHud.Snapshot;
+import net.kewwbec.hub.listener.HubHudVisibility;
+import net.kewwbec.networkcore.api.xp.NetworkXp;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.awt.Color;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Level;
+
+public final class KothService {
+
+ public enum Phase { ACTIVE, INTERMISSION }
+
+ private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
+ private static final int[] ANNOUNCE_THRESHOLDS = {60, 30, 10};
+
+ private final HubConfig.KothSection cfg;
+ @Nullable private final NetworkXp xp;
+ @Nullable private final HubHudVisibility hudVisibility;
+
+ private final Map arenaPlayers = new ConcurrentHashMap<>();
+ private final Map accumulatedMs = new ConcurrentHashMap<>();
+ private final Set currentCapturers = ConcurrentHashMap.newKeySet();
+ private final Set announcedThresholds = ConcurrentHashMap.newKeySet();
+
+ private volatile Phase phase = Phase.INTERMISSION;
+ private volatile long phaseStartMs = System.currentTimeMillis();
+
+ @Nullable private ScheduledFuture> tickTask;
+
+ public KothService(@Nonnull HubConfig.KothSection cfg,
+ @Nullable NetworkXp xp,
+ @Nullable HubHudVisibility hudVisibility) {
+ this.cfg = cfg;
+ this.xp = xp;
+ this.hudVisibility = hudVisibility;
+ }
+
+ public void start() {
+ phase = Phase.INTERMISSION;
+ phaseStartMs = System.currentTimeMillis();
+ announcedThresholds.clear();
+ tickTask = HytaleServer.SCHEDULED_EXECUTOR.scheduleAtFixedRate(
+ this::tick, 1L, 1L, TimeUnit.SECONDS);
+ }
+
+ public void stop() {
+ if (tickTask != null) {
+ try { tickTask.cancel(false); } catch (RuntimeException ignored) {}
+ tickTask = null;
+ }
+ for (ArenaPlayer ap : arenaPlayers.values()) {
+ safeOnWorld(ap.playerRef, () -> ap.hud.clear());
+ }
+ arenaPlayers.clear();
+ accumulatedMs.clear();
+ currentCapturers.clear();
+ announcedThresholds.clear();
+ }
+
+ public boolean isInArena(@Nonnull UUID uuid) {
+ return arenaPlayers.containsKey(uuid);
+ }
+
+ public boolean isInActiveArena(@Nonnull UUID uuid) {
+ return phase == Phase.ACTIVE && arenaPlayers.containsKey(uuid);
+ }
+
+ @Nonnull
+ public Phase getPhase() {
+ return phase;
+ }
+
+ public void forceStart() {
+ if (phase == Phase.ACTIVE) return;
+ startRound();
+ }
+
+ public void forceEnd() {
+ if (phase == Phase.INTERMISSION) return;
+ endRound();
+ }
+
+ public void onEnterArena(@Nonnull PlayerRef playerRef) {
+ UUID uuid = playerRef.getUuid();
+ ArenaPlayer ap = new ArenaPlayer(playerRef, new KothHud(playerRef));
+ arenaPlayers.put(uuid, ap);
+ if (phase == Phase.ACTIVE) {
+ accumulatedMs.computeIfAbsent(uuid, k -> 0L);
+ giveSword(playerRef);
+ if (hudVisibility != null) hudVisibility.showAll(playerRef);
+ }
+ ap.hud.render(buildSnapshot(uuid, computeRanking()));
+ }
+
+ public void onExitArena(@Nonnull PlayerRef playerRef) {
+ UUID uuid = playerRef.getUuid();
+ ArenaPlayer removed = arenaPlayers.remove(uuid);
+ if (removed != null) removed.hud.clear();
+ if (phase == Phase.ACTIVE) removeSword(playerRef);
+ if (hudVisibility != null) hudVisibility.applyHidden(playerRef);
+ currentCapturers.remove(uuid);
+ }
+
+ public void onEnterCapture(@Nonnull PlayerRef playerRef) {
+ if (arenaPlayers.containsKey(playerRef.getUuid())) {
+ currentCapturers.add(playerRef.getUuid());
+ }
+ }
+
+ public void onExitCapture(@Nonnull PlayerRef playerRef) {
+ currentCapturers.remove(playerRef.getUuid());
+ }
+
+ public void onDisconnect(@Nonnull PlayerRef ref) {
+ UUID uuid = ref.getUuid();
+ ArenaPlayer removed = arenaPlayers.remove(uuid);
+ if (removed != null) {
+ removed.hud.clear();
+ removeSword(ref);
+ }
+ currentCapturers.remove(uuid);
+ }
+
+ public double getKnockbackMultiplier() {
+ return cfg.knockback_multiplier;
+ }
+
+ private void tick() {
+ try {
+ if (phase == Phase.ACTIVE) {
+ for (UUID id : currentCapturers) {
+ accumulatedMs.merge(id, 1000L, Long::sum);
+ }
+ }
+ List> ranking = computeRanking();
+ long remainingSec = secondsRemaining();
+ if (phase == Phase.INTERMISSION) maybeAnnounceCountdown(remainingSec);
+
+ pushHud(ranking);
+
+ if (remainingSec <= 0L) {
+ if (phase == Phase.ACTIVE) endRound();
+ else startRound();
+ }
+ } catch (RuntimeException e) {
+ ((HytaleLogger.Api) LOGGER.at(Level.WARNING).withCause(e)).log("KOTH tick failed");
+ }
+ }
+
+ private void maybeAnnounceCountdown(long remainingSec) {
+ for (int t : ANNOUNCE_THRESHOLDS) {
+ if (remainingSec > 0L && remainingSec <= t && announcedThresholds.add(t)) {
+ broadcastAll(formatCountdown(t));
+ }
+ }
+ }
+
+ @Nonnull
+ private static String formatCountdown(int secs) {
+ if (secs >= 60) {
+ int m = secs / 60;
+ return "KOTH starting in " + m + " minute" + (m == 1 ? "" : "s") + "!";
+ }
+ return "KOTH starting in " + secs + " seconds!";
+ }
+
+ private void pushHud(@Nonnull List> ranking) {
+ for (ArenaPlayer ap : arenaPlayers.values()) {
+ Snapshot snap = buildSnapshot(ap.playerRef.getUuid(), ranking);
+ safeOnWorld(ap.playerRef, () -> ap.hud.render(snap));
+ }
+ }
+
+ private void endRound() {
+ List> ranking = computeRanking();
+ ranking.removeIf(e -> e.getValue() <= 0L);
+
+ if (ranking.isEmpty()) {
+ broadcastArena("KOTH round ended with no participants.");
+ } else if (ranking.size() == 1) {
+ UUID solo = ranking.get(0).getKey();
+ long soloMs = ranking.get(0).getValue();
+ broadcastArena("KOTH round ended. Solo capture by " + lookupName(solo)
+ + " (" + (soloMs / 1000) + "s) - no XP awarded.");
+ } else {
+ broadcastArena("KOTH round ended!");
+ int participation = 0;
+ long participationAwarded = 0L;
+ for (int i = 0; i < ranking.size(); i++) {
+ UUID uuid = ranking.get(i).getKey();
+ long timeMs = ranking.get(i).getValue();
+ long reward = rewardForRank(i);
+ String tag = rankTag(i);
+ if (xp != null && reward > 0L) {
+ xp.add(uuid, reward, "koth_" + tag);
+ }
+ if (i < 3) {
+ broadcastArena(ordinal(i + 1) + ": " + lookupName(uuid)
+ + " " + (timeMs / 1000) + "s"
+ + (reward > 0L ? " +" + reward + " XP" : ""));
+ } else {
+ participation++;
+ participationAwarded = reward;
+ }
+ }
+ if (participation > 0) {
+ broadcastArena(participation + " more participant"
+ + (participation == 1 ? "" : "s")
+ + (participationAwarded > 0L ? " got +" + participationAwarded + " XP each" : ""));
+ }
+ }
+
+ accumulatedMs.clear();
+ for (ArenaPlayer ap : arenaPlayers.values()) {
+ removeSword(ap.playerRef);
+ if (hudVisibility != null) hudVisibility.applyHidden(ap.playerRef);
+ }
+ phase = Phase.INTERMISSION;
+ phaseStartMs = System.currentTimeMillis();
+ announcedThresholds.clear();
+ }
+
+ private long rewardForRank(int idx) {
+ return switch (idx) {
+ case 0 -> cfg.xp_first;
+ case 1 -> cfg.xp_second;
+ case 2 -> cfg.xp_third;
+ default -> cfg.xp_participation;
+ };
+ }
+
+ @Nonnull
+ private static String rankTag(int idx) {
+ return switch (idx) {
+ case 0 -> "1st";
+ case 1 -> "2nd";
+ case 2 -> "3rd";
+ default -> "participant";
+ };
+ }
+
+ @Nonnull
+ private static String ordinal(int n) {
+ return switch (n) {
+ case 1 -> "1st";
+ case 2 -> "2nd";
+ case 3 -> "3rd";
+ default -> n + "th";
+ };
+ }
+
+ private void startRound() {
+ for (ArenaPlayer ap : arenaPlayers.values()) {
+ accumulatedMs.computeIfAbsent(ap.playerRef.getUuid(), k -> 0L);
+ giveSword(ap.playerRef);
+ if (hudVisibility != null) hudVisibility.showAll(ap.playerRef);
+ }
+ broadcastAll("KOTH round started. Capture the hill!");
+ phase = Phase.ACTIVE;
+ phaseStartMs = System.currentTimeMillis();
+ announcedThresholds.clear();
+ }
+
+ private long secondsRemaining() {
+ long elapsedMs = System.currentTimeMillis() - phaseStartMs;
+ long limitSec = phase == Phase.ACTIVE ? cfg.round_seconds : cfg.intermission_seconds;
+ return Math.max(0L, (limitSec * 1000L - elapsedMs) / 1000L);
+ }
+
+ @Nonnull
+ private List> computeRanking() {
+ List> sorted = new ArrayList<>(accumulatedMs.entrySet());
+ sorted.sort(Comparator.comparingLong(Map.Entry::getValue).reversed());
+ return sorted;
+ }
+
+ @Nonnull
+ private Snapshot buildSnapshot(@Nonnull UUID viewerUuid, @Nonnull List> ranking) {
+ int onHill = currentCapturers.size();
+
+ List board = new ArrayList<>(3);
+ for (int i = 0; i < Math.min(3, ranking.size()); i++) {
+ UUID id = ranking.get(i).getKey();
+ board.add(new BoardEntry(id, lookupName(id), ranking.get(i).getValue()));
+ }
+
+ Integer yourRank = null;
+ Long yourTimeMs = null;
+ for (int i = 0; i < ranking.size(); i++) {
+ if (ranking.get(i).getKey().equals(viewerUuid)) {
+ int rankOneBased = i + 1;
+ if (rankOneBased > 3) {
+ yourRank = rankOneBased;
+ yourTimeMs = ranking.get(i).getValue();
+ }
+ break;
+ }
+ }
+ if (yourRank == null && !arenaPlayers.containsKey(viewerUuid)) {
+ yourRank = null;
+ }
+ if (yourRank == null && arenaPlayers.containsKey(viewerUuid)
+ && !accumulatedMs.containsKey(viewerUuid)) {
+ yourRank = ranking.size() + 1;
+ yourTimeMs = 0L;
+ }
+
+ return new Snapshot(phase, onHill, board, yourRank, yourTimeMs, secondsRemaining());
+ }
+
+ private void giveSword(@Nonnull PlayerRef ref) {
+ short slot = (short) Math.max(0, cfg.sword_slot);
+ runOnPlayerEntity(ref, player ->
+ player.getInventory().getHotbar().addItemStackToSlot(slot, new ItemStack(cfg.sword_item_id, 1)));
+ }
+
+ private void removeSword(@Nonnull PlayerRef ref) {
+ runOnPlayerEntity(ref, player ->
+ player.getInventory().getHotbar().removeItemStack(new ItemStack(cfg.sword_item_id, 1)));
+ }
+
+ private void runOnPlayerEntity(@Nonnull PlayerRef ref,
+ @Nonnull java.util.function.Consumer fn) {
+ Ref r = ref.getReference();
+ if (r == null || !r.isValid()) return;
+ Store store = r.getStore();
+ World world = store.getExternalData().getWorld();
+ if (world == null) return;
+ world.execute(() -> {
+ if (!r.isValid()) return;
+ Player p = store.getComponent(r, Player.getComponentType());
+ if (p == null) return;
+ try { fn.accept(p); } catch (RuntimeException e) {
+ ((HytaleLogger.Api) LOGGER.at(Level.WARNING).withCause(e)).log("KOTH inventory op failed");
+ }
+ });
+ }
+
+ private void safeOnWorld(@Nonnull PlayerRef ref, @Nonnull Runnable r) {
+ Ref rf = ref.getReference();
+ if (rf == null || !rf.isValid()) return;
+ World world = rf.getStore().getExternalData().getWorld();
+ if (world == null) return;
+ world.execute(() -> { try { r.run(); } catch (RuntimeException ignored) {} });
+ }
+
+ private void broadcastArena(@Nonnull String text) {
+ for (ArenaPlayer ap : arenaPlayers.values()) {
+ try { ap.playerRef.sendMessage(Message.raw(text).color(new Color(0xD4AF37))); }
+ catch (RuntimeException ignored) {}
+ }
+ }
+
+ private void broadcastAll(@Nonnull String text) {
+ Universe u = Universe.get();
+ if (u == null) {
+ broadcastArena(text);
+ return;
+ }
+ Collection players;
+ try {
+ players = u.getPlayers();
+ } catch (RuntimeException e) {
+ broadcastArena(text);
+ return;
+ }
+ if (players == null) return;
+ Set seen = new HashSet<>();
+ for (PlayerRef p : players) {
+ if (p == null) continue;
+ if (!seen.add(p.getUuid())) continue;
+ try { p.sendMessage(Message.raw(text).color(new Color(0xD4AF37))); }
+ catch (RuntimeException ignored) {}
+ }
+ }
+
+ @Nonnull
+ private String lookupName(@Nonnull UUID uuid) {
+ ArenaPlayer ap = arenaPlayers.get(uuid);
+ if (ap != null) {
+ try { return ap.playerRef.getUsername(); } catch (RuntimeException ignored) {}
+ }
+ return uuid.toString().substring(0, 8);
+ }
+
+ private record ArenaPlayer(@Nonnull PlayerRef playerRef, @Nonnull KothHud hud) {}
+}
diff --git a/src/main/java/net/kewwbec/hub/listener/HubHudVisibility.java b/src/main/java/net/kewwbec/hub/listener/HubHudVisibility.java
new file mode 100644
index 0000000..41c352f
--- /dev/null
+++ b/src/main/java/net/kewwbec/hub/listener/HubHudVisibility.java
@@ -0,0 +1,104 @@
+package net.kewwbec.hub.listener;
+
+import com.hypixel.hytale.logger.HytaleLogger;
+import com.hypixel.hytale.protocol.packets.interface_.HudComponent;
+import com.hypixel.hytale.protocol.packets.interface_.UpdateVisibleHudComponents;
+import com.hypixel.hytale.server.core.universe.PlayerRef;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Consumer;
+import java.util.logging.Level;
+
+public final class HubHudVisibility {
+
+ private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
+
+ private static final EnumSet ALWAYS_HIDDEN =
+ EnumSet.of(HudComponent.InputBindings);
+
+ private final HudComponent[] hiddenLayout;
+ private final HudComponent[] showAllLayout;
+ private final boolean anyHidden;
+ private final Set hidden = ConcurrentHashMap.newKeySet();
+ @Nullable private volatile Consumer changeListener;
+ private volatile boolean globalForceShow = false;
+
+ public HubHudVisibility(@Nonnull List hiddenNames) {
+ EnumSet toHide = EnumSet.noneOf(HudComponent.class);
+ for (String name : hiddenNames) {
+ if (name == null || name.isBlank()) continue;
+ try {
+ toHide.add(HudComponent.valueOf(name.trim()));
+ } catch (IllegalArgumentException ignored) {
+ LOGGER.at(Level.WARNING).log("Unknown HudComponent in hidden_hud_components: %s", name);
+ }
+ }
+ toHide.addAll(ALWAYS_HIDDEN);
+ this.anyHidden = !toHide.isEmpty();
+
+ EnumSet hiddenModeVisible = EnumSet.complementOf(toHide);
+ this.hiddenLayout = hiddenModeVisible.toArray(new HudComponent[0]);
+
+ EnumSet showAllVisible = EnumSet.complementOf(ALWAYS_HIDDEN);
+ this.showAllLayout = showAllVisible.toArray(new HudComponent[0]);
+ }
+
+ public boolean anyHidden() {
+ return anyHidden;
+ }
+
+ public boolean isHidden(@Nonnull UUID uuid) {
+ return hidden.contains(uuid);
+ }
+
+ public void setChangeListener(@Nullable Consumer listener) {
+ this.changeListener = listener;
+ }
+
+ public void applyHidden(@Nonnull PlayerRef ref) {
+ if (!anyHidden) return;
+ if (globalForceShow) {
+ showAll(ref);
+ return;
+ }
+ boolean changed = hidden.add(ref.getUuid());
+ try {
+ ref.getPacketHandler().write(new UpdateVisibleHudComponents(hiddenLayout));
+ } catch (RuntimeException ignored) {}
+ if (changed) notifyChange(ref);
+ }
+
+ public boolean isGlobalForceShow() {
+ return globalForceShow;
+ }
+
+ public void setGlobalForceShow(boolean on) {
+ this.globalForceShow = on;
+ }
+
+ public void showAll(@Nonnull PlayerRef ref) {
+ if (!anyHidden) return;
+ boolean changed = hidden.remove(ref.getUuid());
+ try {
+ ref.getPacketHandler().write(new UpdateVisibleHudComponents(showAllLayout));
+ } catch (RuntimeException ignored) {}
+ if (changed) notifyChange(ref);
+ }
+
+ public void clearPlayer(@Nonnull UUID uuid) {
+ hidden.remove(uuid);
+ }
+
+ private void notifyChange(@Nonnull PlayerRef ref) {
+ Consumer l = changeListener;
+ if (l != null) {
+ try { l.accept(ref); } catch (RuntimeException ignored) {}
+ }
+ }
+}
diff --git a/src/main/java/net/kewwbec/hub/listener/HubInventoryListener.java b/src/main/java/net/kewwbec/hub/listener/HubInventoryListener.java
new file mode 100644
index 0000000..eb0d713
--- /dev/null
+++ b/src/main/java/net/kewwbec/hub/listener/HubInventoryListener.java
@@ -0,0 +1,59 @@
+package net.kewwbec.hub.listener;
+
+import com.hypixel.hytale.component.Ref;
+import com.hypixel.hytale.component.Store;
+import com.hypixel.hytale.logger.HytaleLogger;
+import com.hypixel.hytale.server.core.entity.entities.Player;
+import com.hypixel.hytale.server.core.event.events.player.PlayerReadyEvent;
+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.hub.config.HubConfig;
+import net.kewwbec.hub.protection.HubWorldFilter;
+
+import javax.annotation.Nonnull;
+import java.util.logging.Level;
+
+public final class HubInventoryListener {
+
+ private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
+
+ private final HubConfig.InventorySection cfg;
+ private final HubWorldFilter filter;
+ private final HubHudVisibility hudVisibility;
+
+ public HubInventoryListener(@Nonnull HubConfig.InventorySection cfg,
+ @Nonnull HubWorldFilter filter,
+ @Nonnull HubHudVisibility hudVisibility) {
+ this.cfg = cfg;
+ this.filter = filter;
+ this.hudVisibility = hudVisibility;
+ }
+
+ public void onReady(@Nonnull PlayerReadyEvent event) {
+ Ref ref = event.getPlayerRef();
+ if (ref == null || !ref.isValid()) return;
+ Store store = ref.getStore();
+ PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType());
+ if (playerRef == null) return;
+ World world = store.getExternalData().getWorld();
+ if (world == null || !filter.isHub(world)) return;
+
+ world.execute(() -> {
+ if (!ref.isValid()) return;
+ Player player = store.getComponent(ref, Player.getComponentType());
+ if (player == null) return;
+ try {
+ if (cfg.clear_on_join) {
+ player.getInventory().clear();
+ }
+ byte resting = (byte) Math.max(0, cfg.resting_slot);
+ player.getInventory().setActiveHotbarSlot(ref, resting, store);
+ hudVisibility.applyHidden(playerRef);
+ } catch (RuntimeException e) {
+ LOGGER.at(Level.WARNING).log("Hub inventory/HUD setup failed for %s: %s",
+ playerRef.getUsername(), e.getMessage());
+ }
+ });
+ }
+}
diff --git a/src/main/java/net/kewwbec/hub/listener/HubJoinListener.java b/src/main/java/net/kewwbec/hub/listener/HubJoinListener.java
index 2504251..d5f0b81 100644
--- a/src/main/java/net/kewwbec/hub/listener/HubJoinListener.java
+++ b/src/main/java/net/kewwbec/hub/listener/HubJoinListener.java
@@ -4,7 +4,6 @@ import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.logger.HytaleLogger;
import com.hypixel.hytale.math.vector.Transform;
-import com.hypixel.hytale.server.core.Message;
import com.hypixel.hytale.server.core.event.events.player.PlayerReadyEvent;
import com.hypixel.hytale.server.core.modules.entity.teleport.Teleport;
import com.hypixel.hytale.server.core.universe.PlayerRef;
@@ -14,16 +13,20 @@ import com.hypixel.hytale.server.core.universe.world.spawn.ISpawnProvider;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import net.kewwbec.hub.config.HubConfig;
import net.kewwbec.hub.protection.HubWorldFilter;
+import net.kewwbec.hub.util.HubMessageFormatter;
import javax.annotation.Nonnull;
-import java.awt.Color;
+import java.util.List;
import java.util.logging.Level;
/**
- * On PlayerReadyEvent in a hub world: teleport to spawn and send a private welcome.
+ * On PlayerReadyEvent in a hub world: teleport to spawn and send each configured welcome line.
*
- * Join/leave broadcasts are handled by HubMessageListener (which replaces Hytale's built-in
- * join/leave messages on AddPlayerToWorldEvent / RemovedPlayerFromWorldEvent).
+ * Lines run through {@link HubMessageFormatter}, which resolves %player% / %prefix% / %color% /
+ * %server% placeholders and parses inline &#RRGGBB color codes into composed messages.
+ *
+ * Join/leave broadcasts are handled by HubMessageListener (AddPlayerToWorldEvent /
+ * RemovedPlayerFromWorldEvent).
*/
public final class HubJoinListener {
@@ -63,10 +66,12 @@ public final class HubJoinListener {
}
}
- if (config.messages.welcome != null && !config.messages.welcome.isBlank()) {
- String welcome = config.messages.welcome.replace("{player}", playerRef.getUsername());
+ List lines = config.messages.welcome_lines;
+ if (lines == null || lines.isEmpty()) return;
+ for (String line : lines) {
+ if (line == null || line.isBlank()) continue;
try {
- playerRef.sendMessage(Message.raw(welcome).color(Color.YELLOW));
+ playerRef.sendMessage(HubMessageFormatter.format(line, playerRef.getUsername(), playerRef.getUuid()));
} catch (RuntimeException ignored) {}
}
}
diff --git a/src/main/java/net/kewwbec/hub/listener/HubMessageListener.java b/src/main/java/net/kewwbec/hub/listener/HubMessageListener.java
index c7f7f6b..af44298 100644
--- a/src/main/java/net/kewwbec/hub/listener/HubMessageListener.java
+++ b/src/main/java/net/kewwbec/hub/listener/HubMessageListener.java
@@ -5,24 +5,25 @@ import com.hypixel.hytale.server.core.Message;
import com.hypixel.hytale.server.core.event.events.player.AddPlayerToWorldEvent;
import com.hypixel.hytale.server.core.event.events.player.RemovedPlayerFromWorldEvent;
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.hub.config.HubConfig;
import net.kewwbec.hub.protection.HubWorldFilter;
+import net.kewwbec.hub.util.HubMessageFormatter;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
+import java.util.UUID;
/**
* Replaces (or suppresses) Hytale's default join/leave broadcasts on hub worlds.
*
- * AddPlayerToWorldEvent / RemovedPlayerFromWorldEvent are fired by Hytale's player-add/remove
- * systems with a default joinMessage / leaveMessage. The systems check shouldBroadcastJoinMessage
- * (or leave) after dispatch and broadcast accordingly. We rewrite the message text here, or
- * suppress entirely if the configured format is blank.
+ * Templates run through {@link HubMessageFormatter}: %player%, %prefix%, %color%, %server%
+ * placeholders and inline &#RRGGBB color codes. Empty template suppresses the broadcast.
*/
public final class HubMessageListener {
+ private static final UUID NIL = new UUID(0L, 0L);
+
private final HubConfig config;
private final HubWorldFilter filter;
@@ -33,32 +34,35 @@ public final class HubMessageListener {
public void onAdd(@Nonnull AddPlayerToWorldEvent event) {
if (!filter.isHub(event.getWorld())) return;
- String username = resolveUsername(event.getHolder());
String template = config.messages.join_announcement;
if (template == null || template.isBlank()) {
event.setBroadcastJoinMessage(false);
return;
}
- event.setJoinMessage(Message.raw(template.replace("{player}", username)));
+ Subject s = resolveSubject(event.getHolder());
+ event.setJoinMessage(HubMessageFormatter.format(template, s.name, s.uuid));
event.setBroadcastJoinMessage(true);
}
public void onRemove(@Nonnull RemovedPlayerFromWorldEvent event) {
if (!filter.isHub(event.getWorld())) return;
- String username = resolveUsername(event.getHolder());
String template = config.messages.leave_announcement;
if (template == null || template.isBlank()) {
event.setBroadcastLeaveMessage(false);
return;
}
- event.setLeaveMessage(Message.raw(template.replace("{player}", username)));
+ Subject s = resolveSubject(event.getHolder());
+ event.setLeaveMessage(HubMessageFormatter.format(template, s.name, s.uuid));
event.setBroadcastLeaveMessage(true);
}
@Nonnull
- private static String resolveUsername(@Nullable Holder holder) {
- if (holder == null) return "?";
+ private static Subject resolveSubject(@Nullable Holder holder) {
+ if (holder == null) return new Subject("?", NIL);
PlayerRef ref = holder.getComponent(PlayerRef.getComponentType());
- return (ref == null) ? "?" : ref.getUsername();
+ if (ref == null) return new Subject("?", NIL);
+ return new Subject(ref.getUsername(), ref.getUuid());
}
+
+ private record Subject(@Nonnull String name, @Nonnull UUID uuid) {}
}
diff --git a/src/main/java/net/kewwbec/hub/listener/MenuItemActivateSystem.java b/src/main/java/net/kewwbec/hub/listener/MenuItemActivateSystem.java
new file mode 100644
index 0000000..a7c2bf0
--- /dev/null
+++ b/src/main/java/net/kewwbec/hub/listener/MenuItemActivateSystem.java
@@ -0,0 +1,116 @@
+package net.kewwbec.hub.listener;
+
+import com.hypixel.hytale.component.ArchetypeChunk;
+import com.hypixel.hytale.component.CommandBuffer;
+import com.hypixel.hytale.component.Ref;
+import com.hypixel.hytale.component.Store;
+import com.hypixel.hytale.component.query.Query;
+import com.hypixel.hytale.component.system.EntityEventSystem;
+import com.hypixel.hytale.logger.HytaleLogger;
+import com.hypixel.hytale.server.core.entity.entities.Player;
+import com.hypixel.hytale.protocol.packets.inventory.SetActiveSlot;
+import com.hypixel.hytale.server.core.event.events.ecs.InventorySetActiveSlotEvent;
+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.hub.config.HubConfig;
+import net.kewwbec.hub.koth.KothService;
+import net.kewwbec.hub.protection.HubWorldFilter;
+import net.kewwbec.hub.service.BuildModeService;
+import net.kewwbec.hub.ui.HubMenuPage;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.logging.Level;
+
+public final class MenuItemActivateSystem extends EntityEventSystem {
+
+ private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
+ private static final long DEBOUNCE_MS = 500L;
+
+ private final HubConfig.InventorySection cfg;
+ private final HubWorldFilter filter;
+ @Nullable private final KothService koth;
+ @Nonnull private final BuildModeService buildMode;
+ @Nullable private final HubHudVisibility hudVisibility;
+ private final ConcurrentMap lastOpenMs = new ConcurrentHashMap<>();
+
+ public MenuItemActivateSystem(@Nonnull HubConfig.InventorySection cfg,
+ @Nonnull HubWorldFilter filter,
+ @Nonnull BuildModeService buildMode,
+ @Nullable HubHudVisibility hudVisibility,
+ @Nullable KothService koth) {
+ super(InventorySetActiveSlotEvent.class);
+ this.cfg = cfg;
+ this.filter = filter;
+ this.buildMode = buildMode;
+ this.hudVisibility = hudVisibility;
+ this.koth = koth;
+ }
+
+ @Override
+ @Nonnull
+ public Query getQuery() {
+ return Query.any();
+ }
+
+ @Override
+ public void handle(int chunkSlot,
+ @Nonnull ArchetypeChunk chunk,
+ @Nonnull Store store,
+ @Nonnull CommandBuffer commandBuffer,
+ @Nonnull InventorySetActiveSlotEvent event) {
+ if (!cfg.open_menu_on_slot_switch) return;
+ if (event.getPreviousSlot() == event.getNewSlot()) return;
+ byte resting = (byte) Math.max(0, cfg.resting_slot);
+ if (event.getNewSlot() == resting) return;
+ World world = store.getExternalData().getWorld();
+ if (world == null || !filter.isHub(world)) return;
+
+ PlayerRef playerRef = chunk.getComponent(chunkSlot, PlayerRef.getComponentType());
+ if (playerRef == null) return;
+ if (koth != null && koth.isInActiveArena(playerRef.getUuid())) return;
+ if (buildMode.isOn(playerRef.getUuid())) return;
+ if (hudVisibility != null && hudVisibility.isGlobalForceShow()) return;
+
+ Player player = chunk.getComponent(chunkSlot, Player.getComponentType());
+ if (player == null) return;
+
+ Ref ref = chunk.getReferenceTo(chunkSlot);
+ if (ref == null) return;
+
+ try {
+ playerRef.getPacketHandler().write(new SetActiveSlot(event.getInventorySectionId(), resting));
+ } catch (RuntimeException ignored) {}
+ world.execute(() -> {
+ if (!ref.isValid()) return;
+ Player p = store.getComponent(ref, Player.getComponentType());
+ if (p == null) return;
+ try {
+ p.getInventory().setActiveHotbarSlot(ref, resting, store);
+ } catch (RuntimeException ignored) {}
+ });
+
+ byte menuTrigger = (byte) Math.max(0, cfg.menu_trigger_slot);
+ if (event.getNewSlot() != menuTrigger) return;
+
+ UUID uuid = playerRef.getUuid();
+ long now = System.currentTimeMillis();
+ Long prev = lastOpenMs.put(uuid, now);
+ if (prev != null && now - prev < DEBOUNCE_MS) return;
+
+ try {
+ player.getPageManager().openCustomPage(ref, store, new HubMenuPage(playerRef));
+ } catch (RuntimeException e) {
+ LOGGER.at(Level.WARNING).log("Hub menu open via slot-switch failed for %s: %s",
+ playerRef.getUsername(), e.getMessage());
+ }
+ }
+
+ public void clearPlayer(@Nonnull UUID uuid) {
+ lastOpenMs.remove(uuid);
+ }
+}
diff --git a/src/main/java/net/kewwbec/hub/model/DailyReward.java b/src/main/java/net/kewwbec/hub/model/DailyReward.java
new file mode 100644
index 0000000..1eef0d4
--- /dev/null
+++ b/src/main/java/net/kewwbec/hub/model/DailyReward.java
@@ -0,0 +1,11 @@
+package net.kewwbec.hub.model;
+
+import javax.annotation.Nonnull;
+import java.util.UUID;
+
+public record DailyReward(
+ @Nonnull UUID uuid,
+ long lastClaimAt,
+ int streak
+) {
+}
diff --git a/src/main/java/net/kewwbec/hub/protection/CancelBreakBlockSystem.java b/src/main/java/net/kewwbec/hub/protection/CancelBreakBlockSystem.java
index 651d6c8..d2a2b63 100644
--- a/src/main/java/net/kewwbec/hub/protection/CancelBreakBlockSystem.java
+++ b/src/main/java/net/kewwbec/hub/protection/CancelBreakBlockSystem.java
@@ -5,6 +5,7 @@ import com.hypixel.hytale.component.CommandBuffer;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.component.query.Query;
import com.hypixel.hytale.component.system.EntityEventSystem;
+import com.hypixel.hytale.server.core.Message;
import com.hypixel.hytale.server.core.event.events.ecs.BreakBlockEvent;
import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.World;
@@ -38,9 +39,11 @@ public final class CancelBreakBlockSystem extends EntityEventSystem getGroup() {
+ return DamageModule.get().getFilterDamageGroup();
+ }
+
+ @Override
+ @Nonnull
+ public Query getQuery() {
+ return Query.any();
+ }
+
+ @Override
+ public void handle(int chunkSlot,
+ @Nonnull ArchetypeChunk chunk,
+ @Nonnull Store store,
+ @Nonnull CommandBuffer commandBuffer,
+ @Nonnull Damage event) {
+ if (event.isCancelled()) return;
+ World world = store.getExternalData().getWorld();
+ if (!filter.isHub(world)) return;
+
+ if (koth != null) {
+ PlayerRef victim = chunk.getComponent(chunkSlot, PlayerRef.getComponentType());
+ if (victim != null && koth.isInArena(victim.getUuid())) {
+ event.setAmount(0f);
+ amplifyKnockback(event, koth.getKnockbackMultiplier());
+ return;
+ }
+ }
+
+ event.setCancelled(true);
+ }
+
+ private static void amplifyKnockback(@Nonnull Damage event, double multiplier) {
+ if (multiplier == 1.0) return;
+ KnockbackComponent kb = event.getIfPresentMetaObject(Damage.KNOCKBACK_COMPONENT);
+ if (kb == null) return;
+ Vector3d v = kb.getVelocity();
+ if (v == null) return;
+ kb.setVelocity(new Vector3d(v.x * multiplier, v.y * multiplier, v.z * multiplier));
+ }
+}
diff --git a/src/main/java/net/kewwbec/hub/protection/CancelDropItemSystem.java b/src/main/java/net/kewwbec/hub/protection/CancelDropItemSystem.java
new file mode 100644
index 0000000..a0d7675
--- /dev/null
+++ b/src/main/java/net/kewwbec/hub/protection/CancelDropItemSystem.java
@@ -0,0 +1,40 @@
+package net.kewwbec.hub.protection;
+
+import com.hypixel.hytale.component.ArchetypeChunk;
+import com.hypixel.hytale.component.CommandBuffer;
+import com.hypixel.hytale.component.Store;
+import com.hypixel.hytale.component.query.Query;
+import com.hypixel.hytale.component.system.EntityEventSystem;
+import com.hypixel.hytale.server.core.event.events.ecs.DropItemEvent;
+import com.hypixel.hytale.server.core.universe.world.World;
+import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
+
+import javax.annotation.Nonnull;
+
+public final class CancelDropItemSystem extends EntityEventSystem {
+
+ private final HubWorldFilter filter;
+
+ public CancelDropItemSystem(@Nonnull HubWorldFilter filter) {
+ super(DropItemEvent.class);
+ this.filter = filter;
+ }
+
+ @Override
+ @Nonnull
+ public Query getQuery() {
+ return Query.any();
+ }
+
+ @Override
+ public void handle(int chunkSlot,
+ @Nonnull ArchetypeChunk chunk,
+ @Nonnull Store store,
+ @Nonnull CommandBuffer commandBuffer,
+ @Nonnull DropItemEvent event) {
+ if (event.isCancelled()) return;
+ World world = store.getExternalData().getWorld();
+ if (!filter.isHub(world)) return;
+ event.setCancelled(true);
+ }
+}
diff --git a/src/main/java/net/kewwbec/hub/protection/CancelPlaceBlockSystem.java b/src/main/java/net/kewwbec/hub/protection/CancelPlaceBlockSystem.java
index d6a41a2..751a072 100644
--- a/src/main/java/net/kewwbec/hub/protection/CancelPlaceBlockSystem.java
+++ b/src/main/java/net/kewwbec/hub/protection/CancelPlaceBlockSystem.java
@@ -38,7 +38,7 @@ public final class CancelPlaceBlockSystem extends EntityEventSystem {
+
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private static final Query QUERY = (Query) Query.and(new Query[] {
+ (Query) EntityStatMap.getComponentType(),
+ (Query) PlayerRef.getComponentType()
+ });
+
+ private final HubWorldFilter filter;
+
+ public RefillStaminaSystem(@Nonnull HubWorldFilter filter) {
+ this.filter = filter;
+ }
+
+ @Override
+ @Nonnull
+ public Query getQuery() {
+ return QUERY;
+ }
+
+ @Override
+ public void tick(float dt, int systemIndex, @Nonnull Store store) {
+ World world = store.getExternalData().getWorld();
+ if (world == null || !filter.isHub(world)) return;
+ super.tick(dt, systemIndex, store);
+ }
+
+ @Override
+ public void tick(float dt,
+ int index,
+ @Nonnull ArchetypeChunk chunk,
+ @Nonnull Store store,
+ @Nonnull CommandBuffer commandBuffer) {
+ EntityStatMap statMap = chunk.getComponent(index, EntityStatMap.getComponentType());
+ if (statMap == null) return;
+ int staminaIndex = DefaultEntityStatTypes.getStamina();
+ EntityStatValue stamina = statMap.get(staminaIndex);
+ if (stamina == null) return;
+ float max = stamina.getMax();
+ if (stamina.get() < max) {
+ statMap.setStatValue(staminaIndex, max);
+ }
+ }
+}
diff --git a/src/main/java/net/kewwbec/hub/service/DailyRewardService.java b/src/main/java/net/kewwbec/hub/service/DailyRewardService.java
new file mode 100644
index 0000000..12f2153
--- /dev/null
+++ b/src/main/java/net/kewwbec/hub/service/DailyRewardService.java
@@ -0,0 +1,113 @@
+package net.kewwbec.hub.service;
+
+import com.hypixel.hytale.logger.HytaleLogger;
+import net.kewwbec.hub.config.HubConfig;
+import net.kewwbec.hub.db.DailyRewardRepository;
+import net.kewwbec.hub.model.DailyReward;
+import net.kewwbec.networkcore.api.xp.NetworkXp;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.sql.SQLException;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.logging.Level;
+
+/**
+ * Daily-reward state + claim logic. Backed by MySQL via NetworkCore. Reward amount and cooldown
+ * are config-driven. A successful claim awards network XP through the NetworkXp service.
+ *
+ * Streak rule: if the player claims within {@code cooldown_seconds + streak_grace_seconds} of
+ * their last claim, the streak increments. Past that window, the streak resets to 1.
+ */
+public final class DailyRewardService {
+
+ private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
+
+ private final DailyRewardRepository repo;
+ private final NetworkXp xp;
+ private final HubConfig.RewardsSection cfg;
+
+ public DailyRewardService(@Nonnull DailyRewardRepository repo,
+ @Nullable NetworkXp xp,
+ @Nonnull HubConfig.RewardsSection cfg) {
+ this.repo = repo;
+ this.xp = xp;
+ this.cfg = cfg;
+ }
+
+ /**
+ * Read-only snapshot of a player's reward state.
+ */
+ public record Snapshot(int streak, boolean canClaim, long secondsUntilReady, long xpPerClaim) {
+ }
+
+ public enum ClaimStatus {
+ OK,
+ NOT_READY,
+ DB_ERROR
+ }
+
+ /**
+ * Result of {@link #tryClaim(UUID)}.
+ */
+ public record ClaimResult(@Nonnull ClaimStatus status,
+ long xpGained,
+ int newStreak,
+ long secondsUntilNext) {
+ }
+
+ @Nonnull
+ public Snapshot snapshot(@Nonnull UUID uuid) {
+ long now = System.currentTimeMillis();
+ try {
+ Optional existing = repo.find(uuid);
+ if (existing.isEmpty()) {
+ return new Snapshot(0, true, 0L, cfg.xp_per_claim);
+ }
+ DailyReward r = existing.get();
+ long readyAt = r.lastClaimAt() + cfg.cooldown_seconds * 1000L;
+ if (now >= readyAt) {
+ return new Snapshot(r.streak(), true, 0L, cfg.xp_per_claim);
+ }
+ long secondsLeft = Math.max(0L, (readyAt - now) / 1000L);
+ return new Snapshot(r.streak(), false, secondsLeft, cfg.xp_per_claim);
+ } catch (SQLException e) {
+ LOGGER.at(Level.WARNING).log("Reward snapshot failed for %s: %s", uuid, e.getMessage());
+ return new Snapshot(0, false, cfg.cooldown_seconds, cfg.xp_per_claim);
+ }
+ }
+
+ @Nonnull
+ public ClaimResult tryClaim(@Nonnull UUID uuid) {
+ long now = System.currentTimeMillis();
+ try {
+ Optional existing = repo.find(uuid);
+ int newStreak;
+ if (existing.isEmpty()) {
+ newStreak = 1;
+ } else {
+ DailyReward r = existing.get();
+ long readyAt = r.lastClaimAt() + cfg.cooldown_seconds * 1000L;
+ if (now < readyAt) {
+ long secondsLeft = Math.max(0L, (readyAt - now) / 1000L);
+ return new ClaimResult(ClaimStatus.NOT_READY, 0L, r.streak(), secondsLeft);
+ }
+ long graceEnd = readyAt + cfg.streak_grace_seconds * 1000L;
+ newStreak = (now <= graceEnd) ? r.streak() + 1 : 1;
+ }
+ repo.upsert(uuid, now, newStreak);
+ long awarded = cfg.xp_per_claim;
+ if (xp != null) {
+ xp.add(uuid, awarded, "hub.daily_reward");
+ } else {
+ LOGGER.at(Level.WARNING).log("Reward claim recorded but NetworkXp unavailable; XP not granted for %s", uuid);
+ awarded = 0L;
+ }
+ return new ClaimResult(ClaimStatus.OK, awarded, newStreak, cfg.cooldown_seconds);
+ } catch (SQLException e) {
+ LOGGER.at(Level.WARNING).log("Reward claim failed for %s: %s", uuid, e.getMessage());
+ return new ClaimResult(ClaimStatus.DB_ERROR, 0L, 0, 0L);
+ }
+ }
+}
diff --git a/src/main/java/net/kewwbec/hub/ui/DailyRewardsPage.java b/src/main/java/net/kewwbec/hub/ui/DailyRewardsPage.java
new file mode 100644
index 0000000..d32907e
--- /dev/null
+++ b/src/main/java/net/kewwbec/hub/ui/DailyRewardsPage.java
@@ -0,0 +1,108 @@
+package net.kewwbec.hub.ui;
+
+import com.hypixel.hytale.codec.Codec;
+import com.hypixel.hytale.codec.KeyedCodec;
+import com.hypixel.hytale.codec.builder.BuilderCodec;
+import com.hypixel.hytale.component.Ref;
+import com.hypixel.hytale.component.Store;
+import com.hypixel.hytale.protocol.packets.interface_.CustomPageLifetime;
+import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType;
+import com.hypixel.hytale.server.core.Message;
+import com.hypixel.hytale.server.core.entity.entities.player.pages.InteractiveCustomUIPage;
+import com.hypixel.hytale.server.core.ui.builder.EventData;
+import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder;
+import com.hypixel.hytale.server.core.ui.builder.UIEventBuilder;
+import com.hypixel.hytale.server.core.universe.PlayerRef;
+import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
+import net.kewwbec.hub.service.DailyRewardService;
+
+import javax.annotation.Nonnull;
+import java.awt.Color;
+
+/**
+ * Hub daily-rewards page. Markup at
+ * {@code Common/UI/Custom/KweebecNet_Hub/Pages/DailyRewardsPage.ui} uses CoreUI templates
+ * via {@code KweebecNet/Common.ui}. This driver populates dynamic state and handles the
+ * claim event.
+ */
+public final class DailyRewardsPage extends InteractiveCustomUIPage {
+
+ private final PlayerRef playerRef;
+ private final DailyRewardService rewards;
+
+ public DailyRewardsPage(@Nonnull PlayerRef playerRef, @Nonnull DailyRewardService rewards) {
+ super(playerRef, CustomPageLifetime.CanDismiss, Data.CODEC);
+ this.playerRef = playerRef;
+ this.rewards = rewards;
+ }
+
+ @Override
+ public void build(@Nonnull Ref ref,
+ @Nonnull UICommandBuilder ui,
+ @Nonnull UIEventBuilder events,
+ @Nonnull Store store) {
+ ui.append("KweebecNet_Hub/Pages/DailyRewardsPage.ui");
+
+ DailyRewardService.Snapshot snap = rewards.snapshot(playerRef.getUuid());
+
+ ui.set("#Subtitle.Text", "Welcome back, " + playerRef.getUsername() + ".");
+ ui.set("#StreakLabel.Text", "Current streak: " + snap.streak() + " day(s)");
+ ui.set("#RewardLabel.Text", "+" + snap.xpPerClaim() + " Network XP");
+
+ if (snap.canClaim()) {
+ ui.set("#StatusLabel.Text", "Your daily reward is ready.");
+ ui.set("#ClaimBtn.Disabled", false);
+ events.addEventBinding(CustomUIEventBindingType.Activating, "#ClaimBtn",
+ EventData.of(Data.KEY_ACTION, Data.ACTION_CLAIM));
+ } else {
+ ui.set("#StatusLabel.Text", "Next reward in " + formatDuration(snap.secondsUntilReady()));
+ ui.set("#ClaimBtn.Disabled", true);
+ }
+ }
+
+ @Override
+ public void handleDataEvent(@Nonnull Ref ref,
+ @Nonnull Store store,
+ @Nonnull Data data) {
+ super.handleDataEvent(ref, store, data);
+ if (Data.ACTION_CLAIM.equals(data.action)) {
+ DailyRewardService.ClaimResult r = rewards.tryClaim(playerRef.getUuid());
+ switch (r.status()) {
+ case OK -> playerRef.sendMessage(Message.raw(
+ "+" + r.xpGained() + " Network XP (streak: " + r.newStreak() + ")"
+ ).color(new Color(0x55FF55)));
+ case NOT_READY -> playerRef.sendMessage(Message.raw(
+ "Not ready yet. Try again in " + formatDuration(r.secondsUntilNext())
+ ).color(Color.YELLOW));
+ case DB_ERROR -> playerRef.sendMessage(Message.raw(
+ "Reward system unavailable. Try later."
+ ).color(Color.RED));
+ }
+ sendUpdate();
+ }
+ }
+
+ @Nonnull
+ private static String formatDuration(long seconds) {
+ if (seconds <= 0L) return "now";
+ long h = seconds / 3600L;
+ long m = (seconds % 3600L) / 60L;
+ long s = seconds % 60L;
+ if (h > 0L) return String.format("%dh %02dm", h, m);
+ if (m > 0L) return String.format("%dm %02ds", m, s);
+ return s + "s";
+ }
+
+ public static final class Data {
+ static final String KEY_ACTION = "Action";
+ static final String ACTION_CLAIM = "claim";
+
+ public static final BuilderCodec CODEC = BuilderCodec.builder(Data.class, Data::new)
+ .addField(new KeyedCodec<>(KEY_ACTION, Codec.STRING),
+ (d, v) -> d.action = v,
+ d -> d.action)
+ .build();
+
+ public String action;
+ }
+}
diff --git a/src/main/java/net/kewwbec/hub/ui/HubMenuPage.java b/src/main/java/net/kewwbec/hub/ui/HubMenuPage.java
new file mode 100644
index 0000000..3c1e376
--- /dev/null
+++ b/src/main/java/net/kewwbec/hub/ui/HubMenuPage.java
@@ -0,0 +1,160 @@
+package net.kewwbec.hub.ui;
+
+import com.hypixel.hytale.codec.Codec;
+import com.hypixel.hytale.codec.KeyedCodec;
+import com.hypixel.hytale.codec.builder.BuilderCodec;
+import com.hypixel.hytale.component.Ref;
+import com.hypixel.hytale.component.Store;
+import com.hypixel.hytale.logger.HytaleLogger;
+import com.hypixel.hytale.protocol.packets.interface_.CustomPageLifetime;
+import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType;
+import com.hypixel.hytale.server.core.Message;
+import com.hypixel.hytale.server.core.entity.entities.player.pages.InteractiveCustomUIPage;
+import com.hypixel.hytale.server.core.ui.builder.EventData;
+import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder;
+import com.hypixel.hytale.server.core.ui.builder.UIEventBuilder;
+import com.hypixel.hytale.server.core.universe.PlayerRef;
+import com.hypixel.hytale.server.core.universe.Universe;
+import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
+import net.kewwbec.coreui.api.AvatarHandle;
+import net.kewwbec.coreui.api.AvatarRenderType;
+import net.kewwbec.coreui.api.AvatarRequest;
+import net.kewwbec.coreui.api.Avatars;
+import net.kewwbec.networkcore.NetworkCore;
+import net.kewwbec.networkcore.api.PlayerPresence;
+import net.kewwbec.networkcore.api.ServerInfo;
+import net.kewwbec.networkcore.api.ServerRegistry;
+import net.kewwbec.networkcore.api.guilds.GuildInfo;
+import net.kewwbec.networkcore.api.guilds.GuildLookup;
+import net.kewwbec.networkcore.api.xp.LevelInfo;
+import net.kewwbec.networkcore.api.xp.NetworkXp;
+import net.kewwbec.networkcore.network.RefRequestService;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.awt.Color;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.logging.Level;
+
+public final class HubMenuPage extends InteractiveCustomUIPage {
+
+ private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
+
+ private final PlayerRef playerRef;
+
+ public HubMenuPage(@Nonnull PlayerRef playerRef) {
+ super(playerRef, CustomPageLifetime.CanDismiss, Data.CODEC);
+ this.playerRef = playerRef;
+ }
+
+ @Override
+ public void build(@Nonnull Ref ref,
+ @Nonnull UICommandBuilder ui,
+ @Nonnull UIEventBuilder events,
+ @Nonnull Store store) {
+ ui.append("KweebecNet_Hub/Pages/HubMenuPage.ui");
+
+ Stats stats = readStats(playerRef);
+ ui.set("#Username.Text", playerRef.getUsername());
+ ui.set("#StatPlaytime #RowValue.Text", stats.playtime);
+ ui.set("#StatLevel #RowValue.Text", String.valueOf(stats.level));
+ ui.set("#StatGuild #RowValue.Text", stats.guild);
+ ui.set("#StatWorld #RowValue.Text", stats.worldOnline + " online");
+ ui.set("#StatNetwork #RowValue.Text", stats.networkOnline + " online");
+
+ events.addEventBinding(CustomUIEventBindingType.Activating, "#PlayBtn",
+ EventData.of(Data.KEY_ACTION, Data.ACTION_PLAY_SMP));
+ }
+
+ @Nullable
+ private AvatarHandle resolveAvatarHandle() {
+ Avatars avatars = NetworkCore.getInstance().findService(Avatars.class);
+ if (avatars == null) return null;
+ AvatarRequest req = AvatarRequest.forUsername(playerRef.getUsername())
+ .renderType(AvatarRenderType.HEAD)
+ .size(256)
+ .rotate(25);
+ AvatarHandle cached = avatars.getCached(playerRef, req);
+ if (cached != null) return cached;
+ try {
+ return avatars.install(playerRef, req);
+ } catch (RuntimeException e) {
+ ((HytaleLogger.Api) LOGGER.at(Level.WARNING).withCause(e)).log("Hub menu avatar install failed for %s", playerRef.getUsername());
+ return null;
+ }
+ }
+
+ @Override
+ public void handleDataEvent(@Nonnull Ref ref,
+ @Nonnull Store store,
+ @Nonnull Data data) {
+ super.handleDataEvent(ref, store, data);
+ if (Data.ACTION_PLAY_SMP.equals(data.action)) {
+ sendToSmp();
+ }
+ }
+
+ private static final String SMP_ROLE = "us-smp";
+
+ private void sendToSmp() {
+ NetworkCore core = NetworkCore.getInstance();
+ ServerRegistry registry = core.getServerRegistry();
+ RefRequestService transfer = core.getRefRequestService();
+ if (registry == null || transfer == null) {
+ playerRef.sendMessage(Message.raw("SMP transfer unavailable.").color(Color.RED));
+ return;
+ }
+ Collection candidates = registry.getServersByRole(SMP_ROLE);
+ ServerInfo target = candidates.stream()
+ .filter(ServerInfo::isReferrable)
+ .min(Comparator.comparingInt(ServerInfo::players))
+ .orElse(null);
+ if (target == null) {
+ playerRef.sendMessage(Message.raw("No SMP server is online right now.").color(Color.YELLOW));
+ return;
+ }
+ if (!transfer.referLocal(playerRef, target)) {
+ playerRef.sendMessage(Message.raw("Couldn't send you to " + target.id() + ".").color(Color.RED));
+ }
+ }
+
+ @Nonnull
+ private static Stats readStats(@Nonnull PlayerRef p) {
+ NetworkCore core = NetworkCore.getInstance();
+ int level = 0;
+ NetworkXp xp = core.getNetworkXp();
+ if (xp != null) {
+ LevelInfo info = xp.getInfo(p.getUuid());
+ level = info.level();
+ }
+ String guildName = "-";
+ GuildLookup gl = core.getGuildLookup();
+ if (gl != null) {
+ GuildInfo g = gl.getGuildOf(p.getUuid());
+ if (g != null) guildName = g.name();
+ }
+ int worldOnline = 0;
+ Universe u = Universe.get();
+ if (u != null) worldOnline = u.getPlayers().size();
+ int netOnline = 0;
+ PlayerPresence presence = core.getPlayerPresence();
+ if (presence != null) netOnline = presence.onlineCount();
+ return new Stats("-", level, guildName, worldOnline, netOnline);
+ }
+
+ private record Stats(String playtime, int level, String guild, int worldOnline, int networkOnline) {}
+
+ public static final class Data {
+ static final String KEY_ACTION = "Action";
+ static final String ACTION_PLAY_SMP = "play_smp";
+
+ public static final BuilderCodec CODEC = BuilderCodec.builder(Data.class, Data::new)
+ .addField(new KeyedCodec<>(KEY_ACTION, Codec.STRING),
+ (d, v) -> d.action = v,
+ d -> d.action)
+ .build();
+
+ public String action;
+ }
+}
diff --git a/src/main/java/net/kewwbec/hub/ui/MenuHintHud.java b/src/main/java/net/kewwbec/hub/ui/MenuHintHud.java
new file mode 100644
index 0000000..426e822
--- /dev/null
+++ b/src/main/java/net/kewwbec/hub/ui/MenuHintHud.java
@@ -0,0 +1,25 @@
+package net.kewwbec.hub.ui;
+
+import com.hypixel.hytale.server.core.entity.entities.player.hud.CustomUIHud;
+import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder;
+import com.hypixel.hytale.server.core.universe.PlayerRef;
+
+import javax.annotation.Nonnull;
+
+public final class MenuHintHud extends CustomUIHud {
+
+ private static final String UI_FILE = "KweebecNet_Hub/Hud/MenuHintHud.ui";
+
+ public MenuHintHud(@Nonnull PlayerRef playerRef) {
+ super(playerRef, "KweebecNetMenuHint", 0);
+ }
+
+ @Override
+ protected void build(@Nonnull UICommandBuilder ui) {
+ ui.append(UI_FILE);
+ }
+
+ public void clear() {
+ update(true, new UICommandBuilder());
+ }
+}
diff --git a/src/main/java/net/kewwbec/hub/ui/NetworkXpHud.java b/src/main/java/net/kewwbec/hub/ui/NetworkXpHud.java
new file mode 100644
index 0000000..e81c946
--- /dev/null
+++ b/src/main/java/net/kewwbec/hub/ui/NetworkXpHud.java
@@ -0,0 +1,61 @@
+package net.kewwbec.hub.ui;
+
+import com.hypixel.hytale.server.core.entity.entities.player.hud.CustomUIHud;
+import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder;
+import com.hypixel.hytale.server.core.universe.PlayerRef;
+import net.kewwbec.hub.listener.HubHudVisibility;
+import net.kewwbec.networkcore.api.xp.LevelInfo;
+import net.kewwbec.networkcore.api.xp.NetworkXp;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+public final class NetworkXpHud extends CustomUIHud {
+
+ private static final String UI_FILE_MID = "KweebecNet_Hub/Hud/NetworkXpHud.ui";
+ private static final String UI_FILE_LOW = "KweebecNet_Hub/Hud/NetworkXpHud_Low.ui";
+
+ private final NetworkXp xp;
+ @Nullable private final HubHudVisibility hudVisibility;
+
+ public NetworkXpHud(@Nonnull PlayerRef playerRef,
+ @Nullable NetworkXp xp,
+ @Nullable HubHudVisibility hudVisibility) {
+ super(playerRef, "KweebecNetXpBar", 0);
+ this.xp = xp;
+ this.hudVisibility = hudVisibility;
+ }
+
+ @Override
+ protected void build(@Nonnull UICommandBuilder ui) {
+ ui.append(useLowPosition() ? UI_FILE_LOW : UI_FILE_MID);
+
+ LevelInfo info = (xp != null) ? xp.getInfo(getPlayerRef().getUuid()) : null;
+ int level = info != null ? info.level() : 0;
+ long into = info != null ? info.xpIntoLevel() : 0L;
+ long need = info != null ? info.xpForNextLevel() : 0L;
+
+ int fillWeight = 0;
+ int emptyWeight = 1000;
+ if (need > 0L) {
+ double progress = Math.max(0.0, Math.min(1.0, (double) into / (double) need));
+ fillWeight = (int) Math.round(progress * 1000);
+ emptyWeight = 1000 - fillWeight;
+ }
+ ui.set("#XpFill.FlexWeight", fillWeight);
+ ui.set("#XpEmpty.FlexWeight", emptyWeight);
+
+ ui.set("#XpText.Text", "LVL " + level);
+ }
+
+ public void refresh() {
+ UICommandBuilder builder = new UICommandBuilder();
+ build(builder);
+ update(true, builder);
+ }
+
+ private boolean useLowPosition() {
+ if (hudVisibility == null) return false;
+ return hudVisibility.isHidden(getPlayerRef().getUuid());
+ }
+}
diff --git a/src/main/java/net/kewwbec/hub/util/HubMessageFormatter.java b/src/main/java/net/kewwbec/hub/util/HubMessageFormatter.java
new file mode 100644
index 0000000..1c5a9de
--- /dev/null
+++ b/src/main/java/net/kewwbec/hub/util/HubMessageFormatter.java
@@ -0,0 +1,151 @@
+package net.kewwbec.hub.util;
+
+import com.hypixel.hytale.server.core.Message;
+import net.kewwbec.networkcore.NetworkCore;
+import net.kewwbec.networkcore.api.ranks.RankInfo;
+import net.kewwbec.networkcore.api.ranks.RankLookup;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.awt.Color;
+import java.util.UUID;
+
+/**
+ * Resolves Hub message templates into composed Hytale {@link Message}s.
+ *
+ * Supports placeholders:
+ * %player% the player's username
+ * %prefix% their primary rank prefix (or empty)
+ * %color% their primary rank color as a &#RRGGBB code (or empty)
+ * %server% the local server id (looked up via NetworkCore)
+ *
+ * Supports inline color codes:
+ * &#RRGGBB switch to a hex color until the next code or end of line
+ * &r reset to default color
+ *
+ * Legacy {player} is also accepted for backward compatibility.
+ *
+ * The formatter has no internal state; callers construct one per use or share a singleton.
+ */
+public final class HubMessageFormatter {
+
+ private HubMessageFormatter() {
+ }
+
+ /**
+ * Format a single template string into a {@link Message}. The {@code username} and {@code uuid}
+ * identify the subject for placeholder substitution; the rank lookup runs lazily and is
+ * resilient to a missing RankLookup service.
+ */
+ @Nonnull
+ public static Message format(@Nonnull String template,
+ @Nonnull String username,
+ @Nonnull UUID uuid) {
+ String resolved = applyPlaceholders(template, username, uuid);
+ return buildColoredMessage(resolved);
+ }
+
+ @Nonnull
+ private static String applyPlaceholders(@Nonnull String template,
+ @Nonnull String username,
+ @Nonnull UUID uuid) {
+ RankInfo rank = lookupRank(uuid);
+ String prefix = (rank == null || rank.prefix() == null) ? "" : rank.prefix();
+ String colorCode = (rank == null || rank.color() == null || rank.color().isBlank())
+ ? "" : "&" + rank.color();
+ String serverId = serverId();
+
+ return template
+ .replace("%player%", username)
+ .replace("{player}", username)
+ .replace("%prefix%", prefix)
+ .replace("%color%", colorCode)
+ .replace("%server%", serverId);
+ }
+
+ @Nullable
+ private static RankInfo lookupRank(@Nonnull UUID uuid) {
+ try {
+ RankLookup l = NetworkCore.getInstance().getRankLookup();
+ return l == null ? null : l.getPrimaryRank(uuid);
+ } catch (RuntimeException ignored) {
+ return null;
+ }
+ }
+
+ @Nonnull
+ private static String serverId() {
+ try { return NetworkCore.getInstance().getServerId(); }
+ catch (RuntimeException ignored) { return ""; }
+ }
+
+ /**
+ * Parses a string containing inline color codes ({@code RRGGBB} and {@code &r}) and builds a
+ * {@link Message} where each segment carries its own color. Plain text with no codes returns a
+ * single uncolored raw message.
+ */
+ @Nonnull
+ public static Message buildColoredMessage(@Nonnull String text) {
+ if (text.isEmpty()) return Message.raw("");
+
+ Message composed = Message.empty();
+ StringBuilder segment = new StringBuilder();
+ Color currentColor = null;
+ boolean anySegmentEmitted = false;
+
+ int i = 0;
+ while (i < text.length()) {
+ char ch = text.charAt(i);
+ // Hex color code: RRGGBB
+ if (ch == '&' && i + 8 <= text.length() && text.charAt(i + 1) == '#'
+ && isHex6(text, i + 2)) {
+ if (segment.length() > 0) {
+ composed = composed.insert(colorize(Message.raw(segment.toString()), currentColor));
+ anySegmentEmitted = true;
+ segment.setLength(0);
+ }
+ try {
+ currentColor = new Color(Integer.parseInt(text.substring(i + 2, i + 8), 16));
+ } catch (NumberFormatException ignored) {
+ currentColor = null;
+ }
+ i += 8;
+ continue;
+ }
+ // Reset: &r
+ if (ch == '&' && i + 2 <= text.length() && text.charAt(i + 1) == 'r') {
+ if (segment.length() > 0) {
+ composed = composed.insert(colorize(Message.raw(segment.toString()), currentColor));
+ anySegmentEmitted = true;
+ segment.setLength(0);
+ }
+ currentColor = null;
+ i += 2;
+ continue;
+ }
+ segment.append(ch);
+ i++;
+ }
+ if (segment.length() > 0) {
+ composed = composed.insert(colorize(Message.raw(segment.toString()), currentColor));
+ anySegmentEmitted = true;
+ }
+ return anySegmentEmitted ? composed : Message.raw(text);
+ }
+
+ @Nonnull
+ private static Message colorize(@Nonnull Message m, @Nullable Color c) {
+ return c == null ? m : m.color(c);
+ }
+
+ private static boolean isHex6(@Nonnull String s, int from) {
+ if (from + 6 > s.length()) return false;
+ for (int i = from; i < from + 6; i++) {
+ char ch = s.charAt(i);
+ if (!((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F'))) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/src/main/resources/Common/UI/Custom/KweebecNet_Hub/Hud/KothHud.ui b/src/main/resources/Common/UI/Custom/KweebecNet_Hub/Hud/KothHud.ui
new file mode 100644
index 0000000..89a48d4
--- /dev/null
+++ b/src/main/resources/Common/UI/Custom/KweebecNet_Hub/Hud/KothHud.ui
@@ -0,0 +1,58 @@
+Group #KothHud {
+ Anchor: (Top: 80, Right: 20, Width: 260, Height: 200);
+ LayoutMode: Top;
+ Background: #1F2A40;
+ OutlineColor: #D4AF37;
+ OutlineSize: 1;
+ Padding: (Full: 12);
+
+ Label #KothTitle {
+ Anchor: (Height: 22, Bottom: 4);
+ Style: (FontSize: 16, TextColor: #D4AF37, HorizontalAlignment: Center, RenderBold: true, LetterSpacing: 2);
+ Text: "KING OF THE HILL";
+ }
+
+ Label #KothTimer {
+ Anchor: (Height: 16, Bottom: 10);
+ Style: (FontSize: 12, TextColor: #8898B5, HorizontalAlignment: Center, RenderItalics: true);
+ Text: "";
+ }
+
+ Group #KothSeparatorTop {
+ Anchor: (Height: 1, Bottom: 8);
+ Background: #2A3A55;
+ }
+
+ Label #KothKing {
+ Anchor: (Height: 18, Bottom: 8);
+ Style: (FontSize: 14, TextColor: #55FF55, HorizontalAlignment: Center, RenderBold: true);
+ Text: "";
+ }
+
+ Label #KothBoardLine1 {
+ Anchor: (Height: 14, Bottom: 2);
+ Style: (FontSize: 12, TextColor: #FFFFFF);
+ Text: "";
+ }
+ Label #KothBoardLine2 {
+ Anchor: (Height: 14, Bottom: 2);
+ Style: (FontSize: 12, TextColor: #DDE3EE);
+ Text: "";
+ }
+ Label #KothBoardLine3 {
+ Anchor: (Height: 14, Bottom: 6);
+ Style: (FontSize: 12, TextColor: #BBBBBB);
+ Text: "";
+ }
+
+ Group #KothSeparatorBottom {
+ Anchor: (Height: 1, Bottom: 4);
+ Background: #2A3A55;
+ }
+
+ Label #KothYourStats {
+ Anchor: (Height: 14);
+ Style: (FontSize: 12, TextColor: #FFAA55, HorizontalAlignment: Center, RenderBold: true);
+ Text: "";
+ }
+}
diff --git a/src/main/resources/Common/UI/Custom/KweebecNet_Hub/Hud/MenuHintHud.ui b/src/main/resources/Common/UI/Custom/KweebecNet_Hub/Hud/MenuHintHud.ui
new file mode 100644
index 0000000..5e138ad
--- /dev/null
+++ b/src/main/resources/Common/UI/Custom/KweebecNet_Hub/Hud/MenuHintHud.ui
@@ -0,0 +1,11 @@
+Group #MenuHintHud {
+ Anchor: (Bottom: 50, Right: 50, Width: 160, Height: 28);
+ LayoutMode: Right;
+
+ ActionButton #MenuHintAction {
+ ActionName: "Menu";
+ KeyBindingLabel: "1";
+ Alignment: Left;
+ HitTestVisible: false;
+ }
+}
diff --git a/src/main/resources/Common/UI/Custom/KweebecNet_Hub/Hud/NetworkXpHud.ui b/src/main/resources/Common/UI/Custom/KweebecNet_Hub/Hud/NetworkXpHud.ui
new file mode 100644
index 0000000..1275a83
--- /dev/null
+++ b/src/main/resources/Common/UI/Custom/KweebecNet_Hub/Hud/NetworkXpHud.ui
@@ -0,0 +1,29 @@
+Group #NetworkXpHud {
+ Anchor: (Bottom: 155, Height: 18, Left: 0, Right: 0);
+ LayoutMode: CenterMiddle;
+
+ Group #XpBar {
+ Anchor: (Width: 360, Height: 18);
+ Background: #1F2A40;
+ OutlineColor: #D4AF37;
+ OutlineSize: 1;
+
+ Group #XpRow {
+ Anchor: (Full: 1);
+ LayoutMode: Left;
+ Group #XpFill {
+ FlexWeight: 0;
+ Background: #D4AF37;
+ }
+ Group #XpEmpty {
+ FlexWeight: 1;
+ }
+ }
+
+ Label #XpText {
+ Anchor: (Full: 0);
+ Style: (FontSize: 12, TextColor: #FFFFFF, HorizontalAlignment: Center, VerticalAlignment: Center, RenderBold: true);
+ Text: "";
+ }
+ }
+}
diff --git a/src/main/resources/Common/UI/Custom/KweebecNet_Hub/Hud/NetworkXpHud_Low.ui b/src/main/resources/Common/UI/Custom/KweebecNet_Hub/Hud/NetworkXpHud_Low.ui
new file mode 100644
index 0000000..7e368e1
--- /dev/null
+++ b/src/main/resources/Common/UI/Custom/KweebecNet_Hub/Hud/NetworkXpHud_Low.ui
@@ -0,0 +1,29 @@
+Group #NetworkXpHud {
+ Anchor: (Bottom: 50, Height: 18, Left: 0, Right: 0);
+ LayoutMode: CenterMiddle;
+
+ Group #XpBar {
+ Anchor: (Width: 360, Height: 18);
+ Background: #1F2A40;
+ OutlineColor: #D4AF37;
+ OutlineSize: 1;
+
+ Group #XpRow {
+ Anchor: (Full: 1);
+ LayoutMode: Left;
+ Group #XpFill {
+ FlexWeight: 0;
+ Background: #D4AF37;
+ }
+ Group #XpEmpty {
+ FlexWeight: 1;
+ }
+ }
+
+ Label #XpText {
+ Anchor: (Full: 0);
+ Style: (FontSize: 12, TextColor: #FFFFFF, HorizontalAlignment: Center, VerticalAlignment: Center, RenderBold: true);
+ Text: "";
+ }
+ }
+}
diff --git a/src/main/resources/Common/UI/Custom/KweebecNet_Hub/Pages/DailyRewardsPage.ui b/src/main/resources/Common/UI/Custom/KweebecNet_Hub/Pages/DailyRewardsPage.ui
new file mode 100644
index 0000000..2381cab
--- /dev/null
+++ b/src/main/resources/Common/UI/Custom/KweebecNet_Hub/Pages/DailyRewardsPage.ui
@@ -0,0 +1,45 @@
+$KW = "../../KweebecNet/Common.ui";
+
+$KW.@KweebecPageOverlay {
+ Anchor: (Full: 0);
+
+ $KW.@KweebecPanel #Frame {
+ @Text = "HUB REWARDS";
+ @Anchor = Anchor(Width: 560, Height: 420);
+
+ #Content {
+ Group {
+ LayoutMode: Top;
+ FlexWeight: 1;
+ Padding: (Full: 28);
+
+ $KW.@KweebecSubtitle #Subtitle {
+ @Anchor = Anchor(Bottom: 20);
+ }
+
+ Label #StreakLabel {
+ Anchor: (Height: 24, Bottom: 4);
+ Style: (FontSize: 15, TextColor: #FFAA55, HorizontalAlignment: Center, RenderBold: true);
+ Text: "";
+ }
+
+ Label #StatusLabel {
+ Anchor: (Height: 20, Bottom: 18);
+ Style: (FontSize: 13, TextColor: #BBBBBB, HorizontalAlignment: Center);
+ Text: "";
+ }
+
+ Label #RewardLabel {
+ Anchor: (Height: 36, Bottom: 22);
+ Style: (FontSize: 22, TextColor: #55FF55, HorizontalAlignment: Center, RenderBold: true);
+ Text: "";
+ }
+
+ $KW.@KweebecPrimaryButton #ClaimBtn {
+ @Text = "CLAIM REWARD";
+ @Anchor = Anchor(Horizontal: 60);
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/resources/Common/UI/Custom/KweebecNet_Hub/Pages/HubMenuPage.ui b/src/main/resources/Common/UI/Custom/KweebecNet_Hub/Pages/HubMenuPage.ui
new file mode 100644
index 0000000..61bde48
--- /dev/null
+++ b/src/main/resources/Common/UI/Custom/KweebecNet_Hub/Pages/HubMenuPage.ui
@@ -0,0 +1,144 @@
+$KW = "../../KweebecNet/Common.ui";
+
+$KW.@KweebecPageOverlay {
+ Anchor: (Full: 0);
+
+ $KW.@KweebecPanel #Frame {
+ @Text = "KWEEBEC NETWORK";
+ @Anchor = Anchor(Width: 1100, Height: 660);
+
+ #Content {
+ Group {
+ LayoutMode: Left;
+ FlexWeight: 1;
+
+ Group #Sidebar {
+ LayoutMode: Top;
+ Anchor: (Width: 280);
+ Padding: (Full: 20);
+
+ $KW.@KweebecHyvatar #AvatarFrame {
+ @Anchor = Anchor(Height: 220, Bottom: 18);
+ }
+
+ Label #Username {
+ Anchor: (Height: 30, Bottom: 18);
+ Style: (FontSize: 20, TextColor: #FFFFFF, HorizontalAlignment: Center, RenderBold: true);
+ Text: "";
+ }
+
+ $KW.@KweebecSeparator {}
+
+ $KW.@KweebecStatRow #StatPlaytime {
+ @Label = "Playtime";
+ }
+ $KW.@KweebecStatRow #StatLevel {
+ @Label = "Total Level";
+ @ValueColor = #55CCFF;
+ }
+ $KW.@KweebecStatRow #StatGuild {
+ @Label = "Guild";
+ }
+
+ $KW.@KweebecSeparator {}
+
+ $KW.@KweebecStatRow #StatWorld {
+ @Label = "World";
+ }
+ $KW.@KweebecStatRow #StatNetwork {
+ @Label = "Network";
+ }
+
+ $KW.@KweebecSeparator {}
+
+ Label #DiscordLine {
+ Anchor: (Height: 22);
+ Style: (FontSize: 14, TextColor: #7289DA, HorizontalAlignment: Center, RenderBold: true);
+ Text: "discord.gg/kweebec";
+ }
+ }
+
+ Group #CardRow {
+ LayoutMode: Left;
+ FlexWeight: 1;
+ Padding: (Left: 40, Right: 40, Top: 20, Bottom: 20);
+
+ $KW.@KweebecCard #SmpCard {
+ @Anchor = Anchor(Width: 240, Height: 420, Right: 20);
+
+ Group #SmpIconPanel {
+ LayoutMode: CenterMiddle;
+ Anchor: (Height: 180, Bottom: 16);
+ Background: #0E1422;
+ OutlineColor: #2A3A55;
+ OutlineSize: 1;
+ ItemIcon #SmpIcon {
+ Anchor: (Width: 128, Height: 128);
+ ItemId: "Plant_Fruit_Apple";
+ }
+ }
+ Label #SmpCardTitle {
+ Anchor: (Height: 28, Bottom: 6);
+ Style: (FontSize: 22, TextColor: #FFFFFF, HorizontalAlignment: Center, RenderBold: true, LetterSpacing: 2);
+ Text: "SMP";
+ }
+ Label #SmpCardDescription {
+ FlexWeight: 1;
+ Style: (FontSize: 13, TextColor: #8898B5, HorizontalAlignment: Center, Wrap: true);
+ Text: "Survival multiplayer.";
+ }
+ $KW.@KweebecPrimaryButton #PlayBtn {
+ @Text = "PLAY";
+ }
+ }
+
+ $KW.@KweebecCard #ComingSoon1 {
+ @Anchor = Anchor(Width: 240, Height: 420, Right: 20);
+ @Background = #161C2C;
+ @OutlineColor = #232E48;
+
+ Group #ComingSoon1IconPanel {
+ LayoutMode: CenterMiddle;
+ Anchor: (Height: 180, Bottom: 16);
+ Background: #0B1020;
+ OutlineColor: #232E48;
+ OutlineSize: 1;
+ Label {
+ Style: (FontSize: 64, TextColor: #3F4A66, HorizontalAlignment: Center, RenderBold: true);
+ Text: "?";
+ }
+ }
+ Label {
+ Anchor: (Height: 28, Bottom: 6);
+ Style: (FontSize: 18, TextColor: #5A6580, HorizontalAlignment: Center, RenderBold: true, LetterSpacing: 2);
+ Text: "COMING SOON";
+ }
+ }
+
+ $KW.@KweebecCard #ComingSoon2 {
+ @Anchor = Anchor(Width: 240, Height: 420);
+ @Background = #161C2C;
+ @OutlineColor = #232E48;
+
+ Group #ComingSoon2IconPanel {
+ LayoutMode: CenterMiddle;
+ Anchor: (Height: 180, Bottom: 16);
+ Background: #0B1020;
+ OutlineColor: #232E48;
+ OutlineSize: 1;
+ Label {
+ Style: (FontSize: 64, TextColor: #3F4A66, HorizontalAlignment: Center, RenderBold: true);
+ Text: "?";
+ }
+ }
+ Label {
+ Anchor: (Height: 28, Bottom: 6);
+ Style: (FontSize: 18, TextColor: #5A6580, HorizontalAlignment: Center, RenderBold: true, LetterSpacing: 2);
+ Text: "COMING SOON";
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/resources/manifest.json b/src/main/resources/manifest.json
index 47b63eb..2a200fd 100644
--- a/src/main/resources/manifest.json
+++ b/src/main/resources/manifest.json
@@ -2,14 +2,15 @@
"Group": "kewwbec",
"Name": "Hub",
"Version": "0.1.0",
- "Description": "Lobby/hub behavior: protection, welcome message, spawn-on-join",
- "IncludesAssetPack": false,
+ "Description": "Lobby/hub behavior: protection, welcome message, spawn-on-join, daily rewards",
+ "IncludesAssetPack": true,
"Authors": [
{"Name": "kewwbec"}
],
"ServerVersion": "*",
"Dependencies": {
- "kewwbec:NetworkCore": "*"
+ "kewwbec:NetworkCore": "*",
+ "kewwbec:CoreUI": "*"
},
"OptionalDependencies": {},
"DisabledByDefault": false,