diff --git a/docs/01_Overview.md b/docs/01_Overview.md index 2dfd8df..a7496e2 100644 --- a/docs/01_Overview.md +++ b/docs/01_Overview.md @@ -45,6 +45,7 @@ External dependencies: | [listener/BanEnforcer.java](../src/main/java/net/kewwbec/staff/listener/BanEnforcer.java) | `PlayerSetupConnectEvent` -> cancel + set reason if banned. | | [listener/ChatEnforcer.java](../src/main/java/net/kewwbec/staff/listener/ChatEnforcer.java) | `PlayerChatEvent` -> cancel if muted; otherwise route to staff chat if toggle is on. | | [listener/PlayerSeenTracker.java](../src/main/java/net/kewwbec/staff/listener/PlayerSeenTracker.java) | Connect/disconnect -> update players_seen, load/clear mute cache, clear staff-chat toggle. | +| [listener/StaffPresenceListener.java](../src/main/java/net/kewwbec/staff/listener/StaffPresenceListener.java) | Connect/disconnect of staff -> announce on staff chat when they actually join/leave the **network** (suppresses server transfers via NetworkCore PlayerPresence + a configurable delay). | | [command/punish/PunishmentCommands.java](../src/main/java/net/kewwbec/staff/command/punish/PunishmentCommands.java) | All 8 punishment commands as nested classes. | | [command/chat/StaffChatCommand.java](../src/main/java/net/kewwbec/staff/command/chat/StaffChatCommand.java) | `/sc` - toggles staff-chat-mode for the caller. | | [command/lookup/LookupCommand.java](../src/main/java/net/kewwbec/staff/command/lookup/LookupCommand.java), [HistoryCommand.java](../src/main/java/net/kewwbec/staff/command/lookup/HistoryCommand.java) | `/lookup`, `/history`. | diff --git a/docs/02_Configuration.md b/docs/02_Configuration.md index 57133f2..5fbf719 100644 --- a/docs/02_Configuration.md +++ b/docs/02_Configuration.md @@ -15,6 +15,10 @@ "tables": { "punishments": "staff_punishments", "players_seen": "staff_players_seen" + }, + "presence": { + "announce_staff_join_leave": true, + "leave_suppression_seconds": 10 } } ``` @@ -37,6 +41,15 @@ Permission gating no longer lives here. Each command checks a permission node (` | `punishments` | `"staff_punishments"` | Table name. Change only if you really need to and you're prepared to migrate data. | | `players_seen` | `"staff_players_seen"` | Same. | +### presence + +| Field | Default | Meaning | +|---|---|---| +| `announce_staff_join_leave` | `true` | When a staff member (anyone with `networkstaff.chat`) joins or leaves the **network**, announce on the staff chat channel. Server-to-server transfers via `/send` or `/hub` are suppressed. | +| `leave_suppression_seconds` | `10` | After a disconnect, wait this many seconds before declaring the player has "left the network." If they reconnect anywhere (any server) in that window, no leave message is sent. Tune higher if your transfers take longer than 10s; tune lower for snappier announcements at the cost of false positives. | + +Requires NetworkCore's `PlayerPresence` service. If NetworkCore isn't running or PlayerPresence is unavailable, presence announcements are silently skipped (with a warning in the boot log). + ## Setting up staff permissions 1. In your Hytale server's permission config, create a `staff` group (or whatever you want to call it). diff --git a/docs/07_UI.md b/docs/07_UI.md index 9db7d38..4ee641d 100644 --- a/docs/07_UI.md +++ b/docs/07_UI.md @@ -102,7 +102,7 @@ This is simpler than HyUI's `updatePage(true)` re-render dance and keeps each pa - `
` - root, dims background, captures input - `
` - main framed window with header - `
` - inner content area -- `` - clickable button (use `class="back-button"` for back-themed) +- `` - clickable button. **Don't use `class="back-button"`** with inline HTML - that class triggers HyUI's `BackButton.ui` template which expects a nested `#HyUIButton` child that inline buttons don't have, and your `Activating` listener fails to bind with "Target element... has no compatible Activating event". Plain ` +
diff --git a/src/main/java/net/kewwbec/staff/ui/PageLayout.java b/src/main/java/net/kewwbec/staff/ui/PageLayout.java new file mode 100644 index 0000000..0475217 --- /dev/null +++ b/src/main/java/net/kewwbec/staff/ui/PageLayout.java @@ -0,0 +1,40 @@ +package net.kewwbec.staff.ui; + +import javax.annotation.Nonnull; + +/** + * Standard outer wrapper for every Staff HyUI page: 800x600 decorated container, scrolling contents. + * + * Use {@link #wrap(String, String)} to produce the full HTML; {@link #escape(String)} for user data. + */ +public final class PageLayout { + + public static final int DEFAULT_WIDTH = 800; + public static final int DEFAULT_HEIGHT = 600; + + private PageLayout() { + } + + @Nonnull + public static String wrap(@Nonnull String title, @Nonnull String contentsHtml) { + return wrap(title, contentsHtml, DEFAULT_WIDTH, DEFAULT_HEIGHT); + } + + @Nonnull + public static String wrap(@Nonnull String title, @Nonnull String contentsHtml, int width, int height) { + return """ +
+
+
+ %s +
+
+
+ """.formatted(escape(title), width, height, contentsHtml); + } + + @Nonnull + public static String escape(@Nonnull String s) { + return s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); + } +} diff --git a/src/main/java/net/kewwbec/staff/ui/PlayerDetailPage.java b/src/main/java/net/kewwbec/staff/ui/PlayerDetailPage.java index 9847b69..91514fb 100644 --- a/src/main/java/net/kewwbec/staff/ui/PlayerDetailPage.java +++ b/src/main/java/net/kewwbec/staff/ui/PlayerDetailPage.java @@ -40,10 +40,13 @@ public final class PlayerDetailPage { this.targetUuid = targetUuid; this.targetName = targetName; - String statusLine = ""; + String statusLine = "Status: no presence record"; String lastSeenLine = ""; - String banLine = ""; - String muteLine = ""; + String banLine = "Not banned."; + String muteLine = "Not muted."; + boolean hasActiveBan = false; + boolean hasActiveMute = false; + int historyCount = 0; try { Optional seen = sCtx.seenRepo.findByUuid(targetUuid); if (seen.isPresent()) { @@ -52,42 +55,62 @@ public final class PlayerDetailPage { ? "Status: ONLINE on " + (s.lastServerId() == null ? "?" : s.lastServerId()) : "Status: offline"; lastSeenLine = "Last seen: " + Instant.ofEpochMilli(s.lastSeen()); - } else { - statusLine = "Status: no presence record"; } Optional ban = sCtx.punishments.getActiveBan(targetUuid); - if (ban.isPresent()) banLine = "BANNED: " + escape(ban.get().reason()) + " (" + describeExpiry(ban.get()) + ")"; + if (ban.isPresent()) { + hasActiveBan = true; + banLine = "BANNED: " + escape(ban.get().reason()) + " (" + describeExpiry(ban.get()) + ")"; + } Optional mute = sCtx.punishments.getActiveMute(targetUuid); - if (mute.isPresent()) muteLine = "MUTED: " + escape(mute.get().reason()) + " (" + describeExpiry(mute.get()) + ")"; + if (mute.isPresent()) { + hasActiveMute = true; + muteLine = "MUTED: " + escape(mute.get().reason()) + " (" + describeExpiry(mute.get()) + ")"; + } + + historyCount = sCtx.punishmentRepo.findHistory(targetUuid, 1000).size(); } catch (SQLException e) { statusLine = "DB error: " + escape(e.getMessage()); } + String unbanBtn = hasActiveBan ? "" : ""; + String unmuteBtn = hasActiveMute ? "" : ""; + String html = """
+

%s

UUID: %s

%s

%s

+

Punishment history: %d total

+

 

+

Active

%s

%s

- - - - - - - - - - +

 

+

Apply punishment

+ + + + + + +

 

+

Lift

+ %s + %s +

 

+

Records

+ +

 

+
- """.formatted(escape(targetName), targetUuid, statusLine, lastSeenLine, banLine, muteLine); + """.formatted(escape(targetName), escape(targetName), targetUuid, statusLine, lastSeenLine, historyCount, banLine, muteLine, unbanBtn, unmuteBtn); this.page = PageBuilder.pageForPlayer(playerRef) .withLifetime(CustomPageLifetime.CanDismiss) @@ -100,12 +123,16 @@ public final class PlayerDetailPage { wirePunish("kickBtn", PunishmentType.KICK); wirePunish("warnBtn", PunishmentType.WARN); - page.addEventListener("unbanBtn", CustomUIEventBindingType.Activating, (ignored, pCtx) -> - revoke(PunishmentType.BAN, PunishmentType.TEMPBAN, "Unbanned") - ); - page.addEventListener("unmuteBtn", CustomUIEventBindingType.Activating, (ignored, pCtx) -> - revoke(PunishmentType.MUTE, PunishmentType.TEMPMUTE, "Unmuted") - ); + if (hasActiveBan) { + page.addEventListener("unbanBtn", CustomUIEventBindingType.Activating, (ignored, pCtx) -> + revoke(PunishmentType.BAN, PunishmentType.TEMPBAN, "Unbanned") + ); + } + if (hasActiveMute) { + page.addEventListener("unmuteBtn", CustomUIEventBindingType.Activating, (ignored, pCtx) -> + revoke(PunishmentType.MUTE, PunishmentType.TEMPMUTE, "Unmuted") + ); + } page.addEventListener("historyBtn", CustomUIEventBindingType.Activating, (ignored, pCtx) -> openStore(s -> new HistoryPage(playerRef, sCtx, targetUuid, targetName).open(s)) diff --git a/src/main/java/net/kewwbec/staff/ui/PlayerLookupPage.java b/src/main/java/net/kewwbec/staff/ui/PlayerLookupPage.java index befbfd2..7f7cb9c 100644 --- a/src/main/java/net/kewwbec/staff/ui/PlayerLookupPage.java +++ b/src/main/java/net/kewwbec/staff/ui/PlayerLookupPage.java @@ -13,6 +13,7 @@ import net.kewwbec.staff.util.PlayerResolver; import javax.annotation.Nonnull; import java.sql.SQLException; +import java.time.Duration; import java.util.LinkedHashMap; import java.util.Map; import java.util.UUID; @@ -35,31 +36,32 @@ public final class PlayerLookupPage { this.playerRef = playerRef; this.sCtx = sCtx; - Map hits = buildHits(query); + Map hits = buildHits(query); StringBuilder rows = new StringBuilder(); int i = 0; - for (Map.Entry e : hits.entrySet()) { - rows.append(""); + for (Row r : hits.values()) { + rows.append("

").append(escape(r.label())).append("

"); + rows.append(""); i++; } String resultsHtml = rows.length() == 0 - ? "

" + (query.isBlank() ? "Type a name and click Search." : "No matches for '" + escape(query) + "'.") + "

" + ? "

" + (query.isBlank() ? "Type a name and click Search. Empty search lists everyone online." : "No matches for '" + escape(query) + "'.") + "

" : rows.toString(); String html = """
+

Search by username. Online players first, then offline players from history.

- -

%d result(s)

-
%s
+ +

 

+

Results: %d

+ %s +

 

+
@@ -74,14 +76,18 @@ public final class PlayerLookupPage { openStore(s -> new PlayerLookupPage(playerRef, sCtx, q).open(s)); }); + page.addEventListener("clearBtn", CustomUIEventBindingType.Activating, (ignored, pCtx) -> + openStore(s -> new PlayerLookupPage(playerRef, sCtx, "").open(s)) + ); + page.addEventListener("backBtn", CustomUIEventBindingType.Activating, (ignored, pCtx) -> openStore(s -> new StaffHubPage(playerRef, sCtx).open(s)) ); int idx = 0; - for (Map.Entry e : hits.entrySet()) { - UUID uuid = e.getKey(); - String name = stripSuffix(e.getValue()); + for (Row r : hits.values()) { + UUID uuid = r.uuid(); + String name = r.name(); page.addEventListener("row" + idx, CustomUIEventBindingType.Activating, (ignored, pCtx) -> openStore(s -> new PlayerDetailPage(playerRef, sCtx, uuid, name).open(s)) ); @@ -100,15 +106,16 @@ public final class PlayerLookupPage { } @Nonnull - private Map buildHits(@Nonnull String q) { - Map out = new LinkedHashMap<>(); + private Map buildHits(@Nonnull String q) { + Map out = new LinkedHashMap<>(); String norm = q.trim().toLowerCase(); Universe universe = Universe.get(); if (universe != null) { for (PlayerRef ref : universe.getPlayers()) { if (norm.isEmpty() || ref.getUsername().toLowerCase().contains(norm)) { - out.put(ref.getUuid(), ref.getUsername() + " (online)"); + out.put(ref.getUuid(), new Row(ref.getUuid(), ref.getUsername(), + ref.getUsername() + " - online here")); if (out.size() >= MAX_ROWS) return out; } } @@ -118,12 +125,13 @@ public final class PlayerLookupPage { try { PlayerResolver.Resolved hit = sCtx.resolver.byName(norm); if (hit != null && !out.containsKey(hit.uuid())) { - String suffix = hit.isOnline() ? " (online)" : " (offline)"; - out.put(hit.uuid(), hit.name() + suffix); + String suffix = hit.isOnline() ? " - online on network" : " - offline"; + out.put(hit.uuid(), new Row(hit.uuid(), hit.name(), hit.name() + suffix)); } else if (hit == null) { - sCtx.seenRepo.findByName(norm).ifPresent(s -> - out.put(s.uuid(), s.lastName() + " (offline)") - ); + sCtx.seenRepo.findByName(norm).ifPresent(s -> { + String label = s.lastName() + " - last seen " + describeAgo(s.lastSeen()) + " ago"; + out.put(s.uuid(), new Row(s.uuid(), s.lastName(), label)); + }); } } catch (SQLException e) { LOGGER.at(Level.WARNING).log("Lookup query failed: %s", e.getMessage()); @@ -133,13 +141,20 @@ public final class PlayerLookupPage { } @Nonnull - private static String stripSuffix(@Nonnull String labelled) { - int i = labelled.lastIndexOf(" ("); - return i < 0 ? labelled : labelled.substring(0, i); + private static String describeAgo(long ts) { + long ms = System.currentTimeMillis() - ts; + if (ms < 0) return "?"; + Duration d = Duration.ofMillis(ms); + if (d.toDays() > 0) return d.toDays() + "d"; + if (d.toHours() > 0) return d.toHours() + "h"; + if (d.toMinutes() > 0) return d.toMinutes() + "m"; + return d.getSeconds() + "s"; } @Nonnull private static String escape(@Nonnull String s) { - return s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); + return s == null ? "" : s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); } + + private record Row(@Nonnull UUID uuid, @Nonnull String name, @Nonnull String label) {} } diff --git a/src/main/java/net/kewwbec/staff/ui/PunishFormPage.java b/src/main/java/net/kewwbec/staff/ui/PunishFormPage.java index 6e3f9e9..11be3ff 100644 --- a/src/main/java/net/kewwbec/staff/ui/PunishFormPage.java +++ b/src/main/java/net/kewwbec/staff/ui/PunishFormPage.java @@ -22,6 +22,16 @@ public final class PunishFormPage { private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + private static final String[] DURATION_PRESETS = { "30m", "1h", "6h", "1d", "3d", "7d", "30d" }; + private static final String[] REASON_PRESETS = { + "Cheating", + "Hate speech", + "Harassment", + "Spam", + "Inappropriate name", + "Exploiting bugs" + }; + private final PlayerRef playerRef; private final StaffUIContext sCtx; private final UUID targetUuid; @@ -51,35 +61,70 @@ public final class PunishFormPage { this.targetName = targetName; this.type = type; - String durationField = type.isTimed() - ? """ - - - """.formatted(escape(duration)) - : ""; + StringBuilder durationBlock = new StringBuilder(); + if (type.isTimed()) { + durationBlock.append("

Duration

"); + durationBlock.append("

Click a preset, or type a custom value (e.g. 30s, 12h, 2w).

"); + for (int i = 0; i < DURATION_PRESETS.length; i++) { + durationBlock.append(""); + } + durationBlock.append(""); + } - String errorLine = error.isBlank() ? "" : "

" + escape(error) + "

"; + StringBuilder reasonPresets = new StringBuilder(); + reasonPresets.append("

Reason

"); + reasonPresets.append("

Pick a preset to fill the field, or type your own.

"); + for (int i = 0; i < REASON_PRESETS.length; i++) { + reasonPresets.append(""); + } + reasonPresets.append(""); + + String errorLine = error.isBlank() ? "" : "

! " + escape(error) + "

"; String html = """
-

Target: %s

+

Target: %s

+

 

%s - - +

 

%s - - +

 

+ %s + +
- """.formatted(type.name(), escape(targetName), durationField, escape(reason), errorLine); + """.formatted(type.name(), escape(targetName), durationBlock.toString(), reasonPresets.toString(), errorLine, type.name()); this.page = PageBuilder.pageForPlayer(playerRef) .withLifetime(CustomPageLifetime.CanDismiss) .fromHtml(html); + if (type.isTimed()) { + for (int i = 0; i < DURATION_PRESETS.length; i++) { + final String preset = DURATION_PRESETS[i]; + page.addEventListener("dPreset" + i, CustomUIEventBindingType.Activating, (ignored, pCtx) -> { + String r = pCtx.getValue("reasonInput", String.class).orElse("").trim(); + rerender(preset, r, ""); + }); + } + } + + for (int i = 0; i < REASON_PRESETS.length; i++) { + final String preset = REASON_PRESETS[i]; + page.addEventListener("rPreset" + i, CustomUIEventBindingType.Activating, (ignored, pCtx) -> { + String d = type.isTimed() ? pCtx.getValue("durationInput", String.class).orElse("").trim() : ""; + rerender(d, preset, ""); + }); + } + page.addEventListener("submitBtn", CustomUIEventBindingType.Activating, (ignored, pCtx) -> { String r = pCtx.getValue("reasonInput", String.class).orElse("").trim(); String d = type.isTimed() ? pCtx.getValue("durationInput", String.class).orElse("").trim() : ""; diff --git a/src/main/java/net/kewwbec/staff/ui/RecentPunishmentsPage.java b/src/main/java/net/kewwbec/staff/ui/RecentPunishmentsPage.java new file mode 100644 index 0000000..26e1536 --- /dev/null +++ b/src/main/java/net/kewwbec/staff/ui/RecentPunishmentsPage.java @@ -0,0 +1,97 @@ +package net.kewwbec.staff.ui; + +import au.ellie.hyui.builders.PageBuilder; +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.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import net.kewwbec.staff.model.Punishment; + +import javax.annotation.Nonnull; +import java.sql.SQLException; +import java.time.Instant; +import java.util.List; +import java.util.function.Consumer; +import java.util.logging.Level; + +public final class RecentPunishmentsPage { + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + private static final int PAGE_SIZE = 50; + + private final PlayerRef playerRef; + private final StaffUIContext ctx; + private final PageBuilder page; + + public RecentPunishmentsPage(@Nonnull PlayerRef playerRef, @Nonnull StaffUIContext ctx) { + this.playerRef = playerRef; + this.ctx = ctx; + + StringBuilder body = new StringBuilder(); + try { + List recent = ctx.punishmentRepo.findRecent(PAGE_SIZE); + if (recent.isEmpty()) { + body.append("

No punishments on record yet.

"); + } else { + long now = System.currentTimeMillis(); + for (Punishment p : recent) { + String state; + if (!p.active() || p.revokedAt() != null) state = "REVOKED"; + else if (p.expiresAt() != null && p.expiresAt() <= now) state = "EXPIRED"; + else state = "ACTIVE"; + + body.append("

#") + .append(p.id()).append(" ") + .append(state).append(" ") + .append(p.type()).append(" ") + .append(escape(p.targetName())).append(" by ") + .append(escape(p.issuedByName())).append(" @ ") + .append(Instant.ofEpochMilli(p.issuedAt())) + .append(" | ") + .append(escape(p.reason())) + .append("

"); + } + } + } catch (SQLException e) { + ((HytaleLogger.Api) LOGGER.at(Level.WARNING).withCause(e)).log("Recent punishments query failed"); + body.append("

DB error: ").append(escape(e.getMessage())).append("

"); + } + + String html = """ +
+
+
+ %s + +
+
+
+ """.formatted(body.toString()); + + this.page = PageBuilder.pageForPlayer(playerRef) + .withLifetime(CustomPageLifetime.CanDismiss) + .fromHtml(html); + + page.addEventListener("backBtn", CustomUIEventBindingType.Activating, (ignored, pCtx) -> + openInStore(s -> new StaffHubPage(playerRef, ctx).open(s)) + ); + } + + public void open(@Nonnull Store store) { + page.open(store); + } + + private void openInStore(@Nonnull Consumer> action) { + Ref ref = playerRef.getReference(); + if (ref == null) return; + action.accept(ref.getStore()); + } + + @Nonnull + private static String escape(@Nonnull String s) { + return s == null ? "" : s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); + } +} diff --git a/src/main/java/net/kewwbec/staff/ui/StaffHelpPage.java b/src/main/java/net/kewwbec/staff/ui/StaffHelpPage.java new file mode 100644 index 0000000..d8f01d1 --- /dev/null +++ b/src/main/java/net/kewwbec/staff/ui/StaffHelpPage.java @@ -0,0 +1,69 @@ +package net.kewwbec.staff.ui; + +import au.ellie.hyui.builders.PageBuilder; +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.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import java.util.function.Consumer; + +public final class StaffHelpPage { + + private final PlayerRef playerRef; + private final StaffUIContext ctx; + private final PageBuilder page; + + public StaffHelpPage(@Nonnull PlayerRef playerRef, @Nonnull StaffUIContext ctx) { + this.playerRef = playerRef; + this.ctx = ctx; + + String html = """ +
+
+
+

Punishments

+

/ban <player> <reason...>

+

/tempban <player> <duration> <reason...>

+

/mute <player> <reason...>

+

/tempmute <player> <duration> <reason...>

+

/kick <player> <reason...>

+

/warn <player> <reason...>

+

/unban <player>

+

/unmute <player>

+

Durations: 30s, 5m, 2h, 7d, 1w

+

Lookup

+

/lookup <player> - active punishments + presence

+

/history <player> - full punishment history

+

Staff chat

+

/sc - toggle staff chat mode (your chat goes to staff)

+

UI

+

/staffui - open this hub

+ +
+
+
+ """; + + this.page = PageBuilder.pageForPlayer(playerRef) + .withLifetime(CustomPageLifetime.CanDismiss) + .fromHtml(html); + + page.addEventListener("backBtn", CustomUIEventBindingType.Activating, (ignored, pCtx) -> + openInStore(s -> new StaffHubPage(playerRef, ctx).open(s)) + ); + } + + public void open(@Nonnull Store store) { + page.open(store); + } + + private void openInStore(@Nonnull Consumer> action) { + Ref ref = playerRef.getReference(); + if (ref == null) return; + action.accept(ref.getStore()); + } +} diff --git a/src/main/java/net/kewwbec/staff/ui/StaffHubPage.java b/src/main/java/net/kewwbec/staff/ui/StaffHubPage.java index 90d780b..291582e 100644 --- a/src/main/java/net/kewwbec/staff/ui/StaffHubPage.java +++ b/src/main/java/net/kewwbec/staff/ui/StaffHubPage.java @@ -1,14 +1,16 @@ package net.kewwbec.staff.ui; -import au.ellie.hyui.builders.ButtonBuilder; import au.ellie.hyui.builders.PageBuilder; +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.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.Universe; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; import javax.annotation.Nonnull; +import java.util.function.Consumer; public final class StaffHubPage { @@ -19,33 +21,63 @@ public final class StaffHubPage { public StaffHubPage(@Nonnull PlayerRef playerRef, @Nonnull StaffUIContext ctx) { this.playerRef = playerRef; this.ctx = ctx; - this.page = PageBuilder.detachedPage() - .withLifetime(CustomPageLifetime.CanDismiss) - .fromHtml(""" -
-
-
-

Pick an action. Press ESC to close.

- -
-
-
- """); - wire(); - } - private void wire() { - page.getById("lookupBtn", ButtonBuilder.class).ifPresent(b -> - b.addEventListener(CustomUIEventBindingType.Activating, e -> openLookup())); + int onlineHere = onlineCount(); + + String body = """ +

Signed in as %s.

+

%d player(s) online on this server.

+

 

+

Players

+ +

 

+

History

+ +

 

+

Help

+ +

 

+ + """.formatted(PageLayout.escape(playerRef.getUsername()), onlineHere); + String html = PageLayout.wrap("Staff Hub", body); + + this.page = PageBuilder.pageForPlayer(playerRef) + .withLifetime(CustomPageLifetime.CanDismiss) + .fromHtml(html); + + page.addEventListener("lookupBtn", CustomUIEventBindingType.Activating, (ignored, pCtx) -> + openInStore(s -> new PlayerLookupPage(playerRef, ctx).open(s)) + ); + page.addEventListener("recentBtn", CustomUIEventBindingType.Activating, (ignored, pCtx) -> + openInStore(s -> new RecentPunishmentsPage(playerRef, ctx).open(s)) + ); + page.addEventListener("helpBtn", CustomUIEventBindingType.Activating, (ignored, pCtx) -> + openInStore(s -> new StaffHelpPage(playerRef, ctx).open(s)) + ); + page.addEventListener("closeBtn", CustomUIEventBindingType.Activating, (ignored, pCtx) -> { + // page is CanDismiss; closing the panel is implicit on ESC. No-op here so the button + // is visually present and a player can choose to dismiss via click. + }); } public void open(@Nonnull Store store) { - page.open(playerRef, store); + page.open(store); } - private void openLookup() { - var ref = playerRef.getReference(); + private int onlineCount() { + Universe u = Universe.get(); + if (u == null) return 0; + return u.getPlayers().size(); + } + + private void openInStore(@Nonnull Consumer> action) { + Ref ref = playerRef.getReference(); if (ref == null) return; - new PlayerLookupPage(playerRef, ctx).open(ref.getStore()); + action.accept(ref.getStore()); + } + + @Nonnull + private static String escape(@Nonnull String s) { + return s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); } }