start of better hub integration for users

This commit is contained in:
2026-05-29 13:28:48 -04:00
parent b43dc289a0
commit 9fc011fc1b
40 changed files with 2756 additions and 48 deletions
@@ -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() {
}
+199 -6
View File
@@ -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<UUID, NetworkXpHud> xpHuds = new ConcurrentHashMap<>();
private final Map<UUID, MenuHintHud> 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<EntityStore> 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<EntityStore> ref = event.getPlayerRef();
if (ref == null || !ref.isValid()) return;
Store<EntityStore> 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<EntityStore> 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<EntityStore> ref = event.getPlayerRef();
if (ref == null || !ref.isValid()) return;
Store<EntityStore> 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");
}
}
@@ -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<EntityStore> store, @Nonnull Ref<EntityStore> 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));
}
}
@@ -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<EntityStore> store,
@Nonnull Ref<EntityStore> 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));
}
}
@@ -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<EntityStore> store,
@Nonnull Ref<EntityStore> 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));
}
}
@@ -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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<PlayerRef> players = u.getPlayers();
if (players != null) {
for (PlayerRef p : players) {
if (p == null) continue;
Ref<EntityStore> 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));
}
}
}
@@ -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<String> 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 = "&#55FF55+ %prefix%%player% &#55FF55joined 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<String> 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;
}
}
@@ -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;
}
}
@@ -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<DailyReward> 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();
}
}
}
@@ -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);
}
}
}
@@ -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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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));
}
}
}
@@ -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<BoardEntry> 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<BoardEntry> board,
@Nullable Integer yourRank,
@Nullable Long yourTimeMs,
long secondsRemaining) {}
}
@@ -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<EntityStore> entityRef = event.getEntityRef();
if (entityRef == null || !entityRef.isValid()) {
debugLog("KOTH: event " + type + " volume=" + event.getVolumeId() + " no entityRef");
return;
}
Store<EntityStore> 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<String, String> 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 ? "<unreachable>" : 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<String, String> 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);
}
}
@@ -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<UUID, ArenaPlayer> arenaPlayers = new ConcurrentHashMap<>();
private final Map<UUID, Long> accumulatedMs = new ConcurrentHashMap<>();
private final Set<UUID> currentCapturers = ConcurrentHashMap.newKeySet();
private final Set<Integer> 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<Map.Entry<UUID, Long>> 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<Map.Entry<UUID, Long>> ranking) {
for (ArenaPlayer ap : arenaPlayers.values()) {
Snapshot snap = buildSnapshot(ap.playerRef.getUuid(), ranking);
safeOnWorld(ap.playerRef, () -> ap.hud.render(snap));
}
}
private void endRound() {
List<Map.Entry<UUID, Long>> 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<Map.Entry<UUID, Long>> computeRanking() {
List<Map.Entry<UUID, Long>> sorted = new ArrayList<>(accumulatedMs.entrySet());
sorted.sort(Comparator.comparingLong(Map.Entry<UUID, Long>::getValue).reversed());
return sorted;
}
@Nonnull
private Snapshot buildSnapshot(@Nonnull UUID viewerUuid, @Nonnull List<Map.Entry<UUID, Long>> ranking) {
int onHill = currentCapturers.size();
List<BoardEntry> 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<Player> fn) {
Ref<EntityStore> r = ref.getReference();
if (r == null || !r.isValid()) return;
Store<EntityStore> 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<EntityStore> 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<PlayerRef> players;
try {
players = u.getPlayers();
} catch (RuntimeException e) {
broadcastArena(text);
return;
}
if (players == null) return;
Set<UUID> 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) {}
}
@@ -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<HudComponent> ALWAYS_HIDDEN =
EnumSet.of(HudComponent.InputBindings);
private final HudComponent[] hiddenLayout;
private final HudComponent[] showAllLayout;
private final boolean anyHidden;
private final Set<UUID> hidden = ConcurrentHashMap.newKeySet();
@Nullable private volatile Consumer<PlayerRef> changeListener;
private volatile boolean globalForceShow = false;
public HubHudVisibility(@Nonnull List<String> hiddenNames) {
EnumSet<HudComponent> 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<HudComponent> hiddenModeVisible = EnumSet.complementOf(toHide);
this.hiddenLayout = hiddenModeVisible.toArray(new HudComponent[0]);
EnumSet<HudComponent> 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<PlayerRef> 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<PlayerRef> l = changeListener;
if (l != null) {
try { l.accept(ref); } catch (RuntimeException ignored) {}
}
}
}
@@ -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<EntityStore> ref = event.getPlayerRef();
if (ref == null || !ref.isValid()) return;
Store<EntityStore> 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());
}
});
}
}
@@ -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 &amp;#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<String> 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) {}
}
}
@@ -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 &amp;#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<EntityStore> holder) {
if (holder == null) return "?";
private static Subject resolveSubject(@Nullable Holder<EntityStore> 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) {}
}
@@ -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<EntityStore, InventorySetActiveSlotEvent> {
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<UUID, Long> 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<EntityStore> getQuery() {
return Query.any();
}
@Override
public void handle(int chunkSlot,
@Nonnull ArchetypeChunk<EntityStore> chunk,
@Nonnull Store<EntityStore> store,
@Nonnull CommandBuffer<EntityStore> 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<EntityStore> 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);
}
}
@@ -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
) {
}
@@ -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<EntityStore,
@Nonnull BreakBlockEvent event) {
if (event.isCancelled()) return;
World world = store.getExternalData().getWorld();
if (world == null || !filter.isHub(world)) return;
if (!filter.isHub(world)) return;
PlayerRef player = chunk.getComponent(chunkSlot, PlayerRef.getComponentType());
if (player != null && buildMode.isOn(player.getUuid())) return;
event.setCancelled(true);
player.sendMessage(Message.raw("you broke " + event.getBlockType().getId()));
}
}
@@ -38,7 +38,7 @@ public final class CancelDamageBlockSystem extends EntityEventSystem<EntityStore
@Nonnull DamageBlockEvent event) {
if (event.isCancelled()) return;
World world = store.getExternalData().getWorld();
if (world == null || !filter.isHub(world)) return;
if (!filter.isHub(world)) return;
PlayerRef player = chunk.getComponent(chunkSlot, PlayerRef.getComponentType());
if (player != null && buildMode.isOn(player.getUuid())) return;
event.setCancelled(true);
@@ -0,0 +1,74 @@
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.SystemGroup;
import com.hypixel.hytale.component.query.Query;
import com.hypixel.hytale.server.core.entity.knockback.KnockbackComponent;
import com.hypixel.hytale.server.core.modules.entity.damage.Damage;
import com.hypixel.hytale.server.core.modules.entity.damage.DamageEventSystem;
import com.hypixel.hytale.server.core.modules.entity.damage.DamageModule;
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.koth.KothService;
import org.joml.Vector3d;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
public final class CancelDamageSystem extends DamageEventSystem {
private final HubWorldFilter filter;
@Nullable private final KothService koth;
public CancelDamageSystem(@Nonnull HubWorldFilter filter, @Nullable KothService koth) {
super();
this.filter = filter;
this.koth = koth;
}
@Override
@Nullable
public SystemGroup<EntityStore> getGroup() {
return DamageModule.get().getFilterDamageGroup();
}
@Override
@Nonnull
public Query<EntityStore> getQuery() {
return Query.any();
}
@Override
public void handle(int chunkSlot,
@Nonnull ArchetypeChunk<EntityStore> chunk,
@Nonnull Store<EntityStore> store,
@Nonnull CommandBuffer<EntityStore> 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));
}
}
@@ -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<EntityStore, DropItemEvent> {
private final HubWorldFilter filter;
public CancelDropItemSystem(@Nonnull HubWorldFilter filter) {
super(DropItemEvent.class);
this.filter = filter;
}
@Override
@Nonnull
public Query<EntityStore> getQuery() {
return Query.any();
}
@Override
public void handle(int chunkSlot,
@Nonnull ArchetypeChunk<EntityStore> chunk,
@Nonnull Store<EntityStore> store,
@Nonnull CommandBuffer<EntityStore> commandBuffer,
@Nonnull DropItemEvent event) {
if (event.isCancelled()) return;
World world = store.getExternalData().getWorld();
if (!filter.isHub(world)) return;
event.setCancelled(true);
}
}
@@ -38,7 +38,7 @@ public final class CancelPlaceBlockSystem extends EntityEventSystem<EntityStore,
@Nonnull PlaceBlockEvent event) {
if (event.isCancelled()) return;
World world = store.getExternalData().getWorld();
if (world == null || !filter.isHub(world)) return;
if (!filter.isHub(world)) return;
PlayerRef player = chunk.getComponent(chunkSlot, PlayerRef.getComponentType());
if (player != null && buildMode.isOn(player.getUuid())) return;
event.setCancelled(true);
@@ -0,0 +1,64 @@
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.tick.EntityTickingSystem;
import com.hypixel.hytale.server.core.modules.entitystats.EntityStatMap;
import com.hypixel.hytale.server.core.modules.entitystats.EntityStatValue;
import com.hypixel.hytale.server.core.modules.entitystats.asset.DefaultEntityStatTypes;
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 javax.annotation.Nonnull;
/**
* Tops every player's stamina back to its max each tick while they are in a hub world.
* Prevents sprinting / swinging from ever draining the bar visibly.
*/
public final class RefillStaminaSystem extends EntityTickingSystem<EntityStore> {
@SuppressWarnings({"unchecked", "rawtypes"})
private static final Query<EntityStore> QUERY = (Query<EntityStore>) 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<EntityStore> getQuery() {
return QUERY;
}
@Override
public void tick(float dt, int systemIndex, @Nonnull Store<EntityStore> 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<EntityStore> chunk,
@Nonnull Store<EntityStore> store,
@Nonnull CommandBuffer<EntityStore> 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);
}
}
}
@@ -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<DailyReward> 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<DailyReward> 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);
}
}
}
@@ -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<DailyRewardsPage.Data> {
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<EntityStore> ref,
@Nonnull UICommandBuilder ui,
@Nonnull UIEventBuilder events,
@Nonnull Store<EntityStore> 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<EntityStore> ref,
@Nonnull Store<EntityStore> 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<Data> CODEC = BuilderCodec.<Data>builder(Data.class, Data::new)
.addField(new KeyedCodec<>(KEY_ACTION, Codec.STRING),
(d, v) -> d.action = v,
d -> d.action)
.build();
public String action;
}
}
@@ -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<HubMenuPage.Data> {
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<EntityStore> ref,
@Nonnull UICommandBuilder ui,
@Nonnull UIEventBuilder events,
@Nonnull Store<EntityStore> 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<EntityStore> ref,
@Nonnull Store<EntityStore> 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<ServerInfo> 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<Data> CODEC = BuilderCodec.<Data>builder(Data.class, Data::new)
.addField(new KeyedCodec<>(KEY_ACTION, Codec.STRING),
(d, v) -> d.action = v,
d -> d.action)
.build();
public String action;
}
}
@@ -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());
}
}
@@ -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());
}
}
@@ -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 &amp;#RRGGBB code (or empty)
* %server% the local server id (looked up via NetworkCore)
*
* Supports inline color codes:
* &amp;#RRGGBB switch to a hex color until the next code or end of line
* &amp;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;
}
}
@@ -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: "";
}
}
@@ -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;
}
}
@@ -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: "";
}
}
}
@@ -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: "";
}
}
}
@@ -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);
}
}
}
}
}
@@ -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";
}
}
}
}
}
}
}
+4 -3
View File
@@ -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,