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