diff --git a/src/main/java/net/kewwbec/staff/StaffPlugin.java b/src/main/java/net/kewwbec/staff/StaffPlugin.java index c226358..4eb582b 100644 --- a/src/main/java/net/kewwbec/staff/StaffPlugin.java +++ b/src/main/java/net/kewwbec/staff/StaffPlugin.java @@ -139,7 +139,7 @@ public final class StaffPlugin extends JavaPlugin { getCommandRegistry().registerCommand(new PunishmentCommands.Warn(punishmentService, staffChat, resolver)); getCommandRegistry().registerCommand(new PunishmentCommands.Unban(punishmentService, staffChat, resolver)); getCommandRegistry().registerCommand(new PunishmentCommands.Unmute(punishmentService, staffChat, resolver)); - getCommandRegistry().registerCommand(new StaffChatCommand(chatToggle)); + getCommandRegistry().registerCommand(new StaffChatCommand(chatToggle, staffChat)); getCommandRegistry().registerCommand(new LookupCommand(resolver, seenRepo, punishmentService)); getCommandRegistry().registerCommand(new HistoryCommand(resolver, punishmentRepo)); diff --git a/src/main/java/net/kewwbec/staff/command/chat/StaffChatCommand.java b/src/main/java/net/kewwbec/staff/command/chat/StaffChatCommand.java index 02dab14..1d23343 100644 --- a/src/main/java/net/kewwbec/staff/command/chat/StaffChatCommand.java +++ b/src/main/java/net/kewwbec/staff/command/chat/StaffChatCommand.java @@ -4,23 +4,35 @@ import com.hypixel.hytale.component.Ref; import com.hypixel.hytale.component.Store; import com.hypixel.hytale.server.core.Message; import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; 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.staff.StaffPermissionsNodes; +import net.kewwbec.staff.service.StaffChatService; import net.kewwbec.staff.service.StaffChatToggleService; import javax.annotation.Nonnull; import java.awt.Color; +/** + * /sc [message...] + * + * If a body is supplied, sends it once to staff chat without changing your chat mode. + * If no body, toggles staff chat mode - all subsequent chat goes to staff until /sc is run again. + */ public final class StaffChatCommand extends AbstractPlayerCommand { private final StaffChatToggleService toggle; + private final StaffChatService staffChat; + private final OptionalArg bodyArg = withOptionalArg("message", "Message body (optional)", ArgTypes.GREEDY_STRING); - public StaffChatCommand(@Nonnull StaffChatToggleService toggle) { - super("sc", "Toggle staff chat mode (your chat goes to staff instead of world)"); + public StaffChatCommand(@Nonnull StaffChatToggleService toggle, @Nonnull StaffChatService staffChat) { + super("sc", "Send a one-shot staff message, or toggle staff chat mode if no message given"); this.toggle = toggle; + this.staffChat = staffChat; requirePermission(StaffPermissionsNodes.CHAT); } @@ -32,6 +44,12 @@ public final class StaffChatCommand extends AbstractPlayerCommand { @Nonnull Ref ref, @Nonnull PlayerRef sender, @Nonnull World world) { + String body = bodyArg.get(ctx); + if (body != null && !body.isBlank()) { + staffChat.send(sender, body.trim()); + return; + } + boolean nowOn = toggle.toggle(sender.getUuid()); if (nowOn) { ctx.sendMessage(Message.raw("Staff chat ON. Your chat now broadcasts to staff. Run /sc again to turn off.").color(Color.GREEN)); diff --git a/src/main/java/net/kewwbec/staff/db/PlayerSeenRepository.java b/src/main/java/net/kewwbec/staff/db/PlayerSeenRepository.java index fbf5a5a..b6d774d 100644 --- a/src/main/java/net/kewwbec/staff/db/PlayerSeenRepository.java +++ b/src/main/java/net/kewwbec/staff/db/PlayerSeenRepository.java @@ -10,6 +10,8 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Types; +import java.util.ArrayList; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -69,6 +71,19 @@ public final class PlayerSeenRepository { return Optional.empty(); } + @Nonnull + public List findRecent(int limit) throws SQLException { + String sql = "SELECT * FROM " + table + " ORDER BY last_seen DESC LIMIT ?"; + List out = new ArrayList<>(); + try (Connection c = ds.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setInt(1, limit); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) out.add(map(rs)); + } + } + return out; + } + @Nonnull public Optional findByName(@Nonnull String name) throws SQLException { String sql = "SELECT * FROM " + table + " WHERE LOWER(last_name) = LOWER(?) ORDER BY last_seen DESC LIMIT 1"; diff --git a/src/main/java/net/kewwbec/staff/ui/ActivityFeedPage.java b/src/main/java/net/kewwbec/staff/ui/ActivityFeedPage.java new file mode 100644 index 0000000..0ee1761 --- /dev/null +++ b/src/main/java/net/kewwbec/staff/ui/ActivityFeedPage.java @@ -0,0 +1,151 @@ +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.PlayerSeen; +import net.kewwbec.staff.model.Punishment; + +import javax.annotation.Nonnull; +import java.sql.SQLException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.function.Consumer; +import java.util.logging.Level; + +/** + * Merged feed of recent network activity: punishments (issued + revoked) and player presence + * changes (joined / left), interleaved by timestamp newest-first. + * + * Three tabs (All / Punishments / Presence) using native HyUI tabs so toggling is client-side. + */ +public final class ActivityFeedPage { + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + private static final int LIMIT = 50; + + private final PlayerRef playerRef; + private final StaffUIContext ctx; + private final PageBuilder page; + + public ActivityFeedPage(@Nonnull PlayerRef playerRef, @Nonnull StaffUIContext ctx) { + this.playerRef = playerRef; + this.ctx = ctx; + + List punishEntries = new ArrayList<>(); + List presenceEntries = new ArrayList<>(); + + try { + for (Punishment p : ctx.punishmentRepo.findRecent(LIMIT)) { + punishEntries.add(new Entry( + p.issuedAt(), + "PUNISH", + p.type().name() + " " + p.targetName() + " by " + p.issuedByName() + " - " + p.reason() + )); + if (p.revokedAt() != null) { + punishEntries.add(new Entry( + p.revokedAt(), + "REVOKE", + "REVOKED " + p.type().name() + " on " + p.targetName() + + " by " + (p.revokedByName() == null ? "?" : p.revokedByName()) + )); + } + } + } catch (SQLException e) { + ((HytaleLogger.Api) LOGGER.at(Level.WARNING).withCause(e)).log("Punishment activity query failed"); + } + + try { + for (PlayerSeen s : ctx.seenRepo.findRecent(LIMIT)) { + presenceEntries.add(new Entry( + s.lastSeen(), + s.online() ? "JOIN" : "LEAVE", + s.lastName() + " " + (s.online() ? "online on " + s.lastServerId() : "offline") + )); + } + } catch (SQLException e) { + ((HytaleLogger.Api) LOGGER.at(Level.WARNING).withCause(e)).log("Presence activity query failed"); + } + + List all = new ArrayList<>(); + all.addAll(punishEntries); + all.addAll(presenceEntries); + all.sort(Comparator.comparingLong(Entry::ts).reversed()); + if (all.size() > LIMIT) all = all.subList(0, LIMIT); + + String body = """ +

Last %d events across the network.

+ +
+ %s +
+
+ %s +
+
+ %s +
+

 

+ + """.formatted(LIMIT, renderList(all), renderList(punishEntries), renderList(presenceEntries)); + + String html = PageLayout.wrap("Activity Feed", body); + + 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))); + } + + @Nonnull + private static String renderList(@Nonnull List entries) { + if (entries.isEmpty()) return "

No activity recorded.

"; + StringBuilder sb = new StringBuilder(); + long now = System.currentTimeMillis(); + for (Entry e : entries) { + sb.append("

[").append(describeAgo(now - e.ts())).append("] ") + .append("[").append(e.kind()).append("] ") + .append(escape(e.text())) + .append("

"); + } + return sb.toString(); + } + + @Nonnull + private static String describeAgo(long ms) { + 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"; + } + + 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("\"", """); + } + + private record Entry(long ts, @Nonnull String kind, @Nonnull String text) {} +} diff --git a/src/main/java/net/kewwbec/staff/ui/HistoryPage.java b/src/main/java/net/kewwbec/staff/ui/HistoryPage.java index 6c3f11b..9e91e64 100644 --- a/src/main/java/net/kewwbec/staff/ui/HistoryPage.java +++ b/src/main/java/net/kewwbec/staff/ui/HistoryPage.java @@ -68,16 +68,9 @@ public final class HistoryPage { body.append("

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

"); } - String html = """ -
-
-
- %s - -
-
-
- """.formatted(escape(targetName), body.toString()); + body.append("

 

"); + body.append(""); + String html = PageLayout.wrap("History: " + targetName, body.toString()); this.page = PageBuilder.pageForPlayer(playerRef) .withLifetime(CustomPageLifetime.CanDismiss) diff --git a/src/main/java/net/kewwbec/staff/ui/PlayerDetailPage.java b/src/main/java/net/kewwbec/staff/ui/PlayerDetailPage.java index 91514fb..2c420ec 100644 --- a/src/main/java/net/kewwbec/staff/ui/PlayerDetailPage.java +++ b/src/main/java/net/kewwbec/staff/ui/PlayerDetailPage.java @@ -77,40 +77,35 @@ public final class PlayerDetailPage { 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), escape(targetName), targetUuid, statusLine, lastSeenLine, historyCount, banLine, muteLine, unbanBtn, unmuteBtn); + String body = """ +

%s

+

UUID: %s

+

%s

+

%s

+

Punishment history: %d total

+

 

+

Active

+

%s

+

%s

+

 

+

Apply punishment

+ + + + + + +

 

+

Lift

+ %s + %s +

 

+

Records

+ +

 

+ + """.formatted(escape(targetName), targetUuid, statusLine, lastSeenLine, historyCount, banLine, muteLine, unbanBtn, unmuteBtn); + String html = PageLayout.wrap(targetName, body); this.page = PageBuilder.pageForPlayer(playerRef) .withLifetime(CustomPageLifetime.CanDismiss) diff --git a/src/main/java/net/kewwbec/staff/ui/PlayerLookupPage.java b/src/main/java/net/kewwbec/staff/ui/PlayerLookupPage.java index 7f7cb9c..3e35022 100644 --- a/src/main/java/net/kewwbec/staff/ui/PlayerLookupPage.java +++ b/src/main/java/net/kewwbec/staff/ui/PlayerLookupPage.java @@ -49,23 +49,18 @@ public final class PlayerLookupPage { ? "

" + (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.

- - - -

 

-

Results: %d

- %s -

 

- -
-
-
+ String body = """ +

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

+ + + +

 

+

Results: %d

+ %s +

 

+ """.formatted(escape(query), hits.size(), resultsHtml); + String html = PageLayout.wrap("Player Lookup", body); this.page = PageBuilder.pageForPlayer(playerRef) .withLifetime(CustomPageLifetime.CanDismiss) diff --git a/src/main/java/net/kewwbec/staff/ui/PunishFormPage.java b/src/main/java/net/kewwbec/staff/ui/PunishFormPage.java index 11be3ff..e19b456 100644 --- a/src/main/java/net/kewwbec/staff/ui/PunishFormPage.java +++ b/src/main/java/net/kewwbec/staff/ui/PunishFormPage.java @@ -22,14 +22,19 @@ 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 = { + private static final String[] DURATION_OPTIONS = { + "30m", "1h", "6h", "1d", "3d", "7d", "30d", "custom" + }; + + private static final String[] REASON_OPTIONS = { + "", "Cheating", "Hate speech", "Harassment", "Spam", "Inappropriate name", - "Exploiting bugs" + "Exploiting bugs", + "Other (type below)" }; private final PlayerRef playerRef; @@ -64,88 +69,86 @@ public final class PunishFormPage { 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("

Pick a preset, or pick 'custom' and type a value (e.g. 12h, 2w).

"); + durationBlock.append(""); + durationBlock.append(""); + durationBlock.append(""); } - 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(""); + StringBuilder reasonBlock = new StringBuilder(); + reasonBlock.append("

Reason

"); + reasonBlock.append("

Pick a preset, or pick 'Other' and type your own.

"); + reasonBlock.append(""); + reasonBlock.append(""); + reasonBlock.append(""); String errorLine = error.isBlank() ? "" : "

! " + escape(error) + "

"; - String html = """ -
-
-
-

Target: %s

-

 

- %s -

 

- %s -

 

- %s - - -
-
-
- """.formatted(type.name(), escape(targetName), durationBlock.toString(), reasonPresets.toString(), errorLine, type.name()); + String body = """ +

Target: %s

+

 

+ %s +

 

+ %s +

 

+ %s + + + """.formatted(escape(targetName), durationBlock.toString(), reasonBlock.toString(), errorLine, type.name()); + + String html = PageLayout.wrap("Issue " + type.name(), body); 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() : ""; + String reasonPick = pCtx.getValue("reasonSelect", String.class).orElse(""); + String reasonCustom = pCtx.getValue("reasonInput", String.class).orElse("").trim(); + String finalReason = (reasonPick.isEmpty() || reasonPick.startsWith("Other")) + ? reasonCustom + : reasonPick; - if (r.isEmpty()) { - rerender(d, r, "Reason is required."); + String durPick = type.isTimed() + ? pCtx.getValue("durationSelect", String.class).orElse("") + : ""; + String durCustom = type.isTimed() + ? pCtx.getValue("durationInput", String.class).orElse("").trim() + : ""; + String finalDuration = "custom".equals(durPick) ? durCustom : durPick; + + if (finalReason.isEmpty()) { + rerender(finalDuration, finalReason, "Reason is required."); return; } Long durationMs = null; if (type.isTimed()) { - if (d.isEmpty()) { - rerender(d, r, "Duration is required for " + type.name() + "."); + if (finalDuration.isEmpty()) { + rerender(finalDuration, finalReason, "Duration is required for " + type.name() + "."); return; } - durationMs = DurationParser.parseMillis(d); + durationMs = DurationParser.parseMillis(finalDuration); if (durationMs == null) { - rerender(d, r, "Bad duration. Use 30s, 5m, 2h, 7d, 1w."); + rerender(finalDuration, finalReason, "Bad duration. Use 30s, 5m, 2h, 7d, 1w."); return; } } - submit(r, durationMs); + submit(finalReason, durationMs); }); page.addEventListener("cancelBtn", CustomUIEventBindingType.Activating, (ignored, pCtx) -> @@ -182,6 +185,22 @@ public final class PunishFormPage { action.accept(ref.getStore()); } + private static boolean matchesPreset(@Nonnull String d, @Nonnull String option) { + if (d.isEmpty()) return option.equals("30m"); + if (option.equals("custom")) return !isPreset(d); + return d.equals(option); + } + + private static boolean isPreset(@Nonnull String d) { + for (String p : DURATION_OPTIONS) if (!p.equals("custom") && p.equals(d)) return true; + return false; + } + + private static boolean isReasonPreset(@Nonnull String r) { + for (String p : REASON_OPTIONS) if (!p.isEmpty() && !p.startsWith("Other") && p.equals(r)) return true; + return false; + } + @Nonnull private static String escape(@Nonnull String s) { return s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); diff --git a/src/main/java/net/kewwbec/staff/ui/RecentPunishmentsPage.java b/src/main/java/net/kewwbec/staff/ui/RecentPunishmentsPage.java index 26e1536..0c7e32c 100644 --- a/src/main/java/net/kewwbec/staff/ui/RecentPunishmentsPage.java +++ b/src/main/java/net/kewwbec/staff/ui/RecentPunishmentsPage.java @@ -60,16 +60,9 @@ public final class RecentPunishmentsPage { body.append("

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

"); } - String html = """ -
-
-
- %s - -
-
-
- """.formatted(body.toString()); + body.append("

 

"); + body.append(""); + String html = PageLayout.wrap("Recent Punishments", body.toString()); this.page = PageBuilder.pageForPlayer(playerRef) .withLifetime(CustomPageLifetime.CanDismiss) diff --git a/src/main/java/net/kewwbec/staff/ui/StaffHelpPage.java b/src/main/java/net/kewwbec/staff/ui/StaffHelpPage.java index d8f01d1..dcf0954 100644 --- a/src/main/java/net/kewwbec/staff/ui/StaffHelpPage.java +++ b/src/main/java/net/kewwbec/staff/ui/StaffHelpPage.java @@ -21,32 +21,31 @@ public final class StaffHelpPage { 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

- -
-
-
+ String body = """ +

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

+

 

+ """; + String html = PageLayout.wrap("Staff Commands", body); this.page = PageBuilder.pageForPlayer(playerRef) .withLifetime(CustomPageLifetime.CanDismiss) diff --git a/src/main/java/net/kewwbec/staff/ui/StaffHubPage.java b/src/main/java/net/kewwbec/staff/ui/StaffHubPage.java index 291582e..60e6c97 100644 --- a/src/main/java/net/kewwbec/staff/ui/StaffHubPage.java +++ b/src/main/java/net/kewwbec/staff/ui/StaffHubPage.java @@ -33,6 +33,7 @@ public final class StaffHubPage {

 

History

+

 

Help

@@ -51,6 +52,9 @@ public final class StaffHubPage { page.addEventListener("recentBtn", CustomUIEventBindingType.Activating, (ignored, pCtx) -> openInStore(s -> new RecentPunishmentsPage(playerRef, ctx).open(s)) ); + page.addEventListener("activityBtn", CustomUIEventBindingType.Activating, (ignored, pCtx) -> + openInStore(s -> new ActivityFeedPage(playerRef, ctx).open(s)) + ); page.addEventListener("helpBtn", CustomUIEventBindingType.Activating, (ignored, pCtx) -> openInStore(s -> new StaffHelpPage(playerRef, ctx).open(s)) );