just pushing what i have the ui is still broken

This commit is contained in:
2026-05-26 23:15:19 -04:00
parent ade5f069e3
commit e236e1bab3
17 changed files with 581 additions and 93 deletions
+1
View File
@@ -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`. |
+13
View File
@@ -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).
+1 -1
View File
@@ -102,7 +102,7 @@ This is simpler than HyUI's `updatePage(true)` re-render dance and keeps each pa
- `<div class="page-overlay">` - root, dims background, captures input
- `<div class="container" data-hyui-title="...">` - main framed window with header
- `<div class="container-contents">` - inner content area
- `<button id="...">Text</button>` - clickable button (use `class="back-button"` for back-themed)
- `<button id="...">Text</button>` - 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 `<button>` works for back buttons too; style separately if you need a different look.
- `<input type="text" id="..." value="..." placeholder="...">` - text input
- `<p>` / `<label>` - text
@@ -21,9 +21,11 @@ import net.kewwbec.staff.config.StaffConfigLoader;
import net.kewwbec.staff.db.PlayerSeenRepository;
import net.kewwbec.staff.db.PunishmentRepository;
import net.kewwbec.staff.db.SchemaBootstrap;
import net.kewwbec.networkcore.api.PlayerPresence;
import net.kewwbec.staff.listener.BanEnforcer;
import net.kewwbec.staff.listener.ChatEnforcer;
import net.kewwbec.staff.listener.PlayerSeenTracker;
import net.kewwbec.staff.listener.StaffPresenceListener;
import net.kewwbec.staff.service.MuteCache;
import net.kewwbec.staff.service.PunishmentService;
import net.kewwbec.staff.service.StaffChatService;
@@ -46,6 +48,7 @@ public final class StaffPlugin extends JavaPlugin {
private StaffChatToggleService chatToggle;
private PunishmentService punishmentService;
private StaffChatService staffChat;
private StaffPresenceListener staffPresenceListener;
public StaffPlugin(@Nonnull JavaPluginInit init) {
super(init);
@@ -116,6 +119,18 @@ public final class StaffPlugin extends JavaPlugin {
getEventRegistry().registerGlobal(PlayerConnectEvent.class, seenTracker::onConnect);
getEventRegistry().registerGlobal(PlayerDisconnectEvent.class, seenTracker::onDisconnect);
if (config.presence.announce_staff_join_leave) {
PlayerPresence presence = core.getPlayerPresence();
if (presence != null) {
this.staffPresenceListener = new StaffPresenceListener(staffChat, permissions, presence,
config.presence.leave_suppression_seconds);
getEventRegistry().registerGlobal(PlayerConnectEvent.class, staffPresenceListener::onConnect);
getEventRegistry().registerGlobal(PlayerDisconnectEvent.class, staffPresenceListener::onDisconnect);
} else {
LOGGER.at(Level.WARNING).log("Staff: NetworkCore PlayerPresence unavailable, skipping staff join/leave announcements");
}
}
getCommandRegistry().registerCommand(new PunishmentCommands.Ban(punishmentService, staffChat, resolver));
getCommandRegistry().registerCommand(new PunishmentCommands.TempBan(punishmentService, staffChat, resolver));
getCommandRegistry().registerCommand(new PunishmentCommands.Mute(punishmentService, staffChat, resolver));
@@ -136,6 +151,10 @@ public final class StaffPlugin extends JavaPlugin {
@Override
protected void shutdown() {
if (staffPresenceListener != null) {
try { staffPresenceListener.stop(); } catch (RuntimeException ignored) {}
staffPresenceListener = null;
}
if (punishmentService != null) {
try { punishmentService.stop(); } catch (RuntimeException ignored) {}
punishmentService = null;
@@ -69,7 +69,7 @@ public final class PunishmentCommands {
private final StaffChatService staffChat;
private final PlayerResolver resolver;
private final RequiredArg<String> targetArg = withRequiredArg("player", "Target player", ArgTypes.STRING);
private final RequiredArg<String> reasonArg = withRequiredArg("reason", "Reason", ArgTypes.STRING);
private final RequiredArg<String> reasonArg = withRequiredArg("reason", "Reason", ArgTypes.GREEDY_STRING);
public Ban(@Nonnull PunishmentService p, @Nonnull StaffChatService c, @Nonnull PlayerResolver r) {
super("ban", "Permanently ban a player");
@@ -99,7 +99,7 @@ public final class PunishmentCommands {
private final PlayerResolver resolver;
private final RequiredArg<String> targetArg = withRequiredArg("player", "Target player", ArgTypes.STRING);
private final RequiredArg<String> durationArg = withRequiredArg("duration", "e.g. 30m, 2h, 7d", ArgTypes.STRING);
private final RequiredArg<String> reasonArg = withRequiredArg("reason", "Reason", ArgTypes.STRING);
private final RequiredArg<String> reasonArg = withRequiredArg("reason", "Reason", ArgTypes.GREEDY_STRING);
public TempBan(@Nonnull PunishmentService p, @Nonnull StaffChatService c, @Nonnull PlayerResolver r) {
super("tempban", "Temporarily ban a player");
@@ -134,7 +134,7 @@ public final class PunishmentCommands {
private final StaffChatService staffChat;
private final PlayerResolver resolver;
private final RequiredArg<String> targetArg = withRequiredArg("player", "Target player", ArgTypes.STRING);
private final RequiredArg<String> reasonArg = withRequiredArg("reason", "Reason", ArgTypes.STRING);
private final RequiredArg<String> reasonArg = withRequiredArg("reason", "Reason", ArgTypes.GREEDY_STRING);
public Mute(@Nonnull PunishmentService p, @Nonnull StaffChatService c, @Nonnull PlayerResolver r) {
super("mute", "Permanently mute a player");
@@ -164,7 +164,7 @@ public final class PunishmentCommands {
private final PlayerResolver resolver;
private final RequiredArg<String> targetArg = withRequiredArg("player", "Target player", ArgTypes.STRING);
private final RequiredArg<String> durationArg = withRequiredArg("duration", "e.g. 30m, 2h", ArgTypes.STRING);
private final RequiredArg<String> reasonArg = withRequiredArg("reason", "Reason", ArgTypes.STRING);
private final RequiredArg<String> reasonArg = withRequiredArg("reason", "Reason", ArgTypes.GREEDY_STRING);
public TempMute(@Nonnull PunishmentService p, @Nonnull StaffChatService c, @Nonnull PlayerResolver r) {
super("tempmute", "Temporarily mute a player");
@@ -199,7 +199,7 @@ public final class PunishmentCommands {
private final StaffChatService staffChat;
private final PlayerResolver resolver;
private final RequiredArg<String> targetArg = withRequiredArg("player", "Target player", ArgTypes.STRING);
private final RequiredArg<String> reasonArg = withRequiredArg("reason", "Reason", ArgTypes.STRING);
private final RequiredArg<String> reasonArg = withRequiredArg("reason", "Reason", ArgTypes.GREEDY_STRING);
public Kick(@Nonnull PunishmentService p, @Nonnull StaffChatService c, @Nonnull PlayerResolver r) {
super("kick", "Kick a player from the network");
@@ -228,7 +228,7 @@ public final class PunishmentCommands {
private final StaffChatService staffChat;
private final PlayerResolver resolver;
private final RequiredArg<String> targetArg = withRequiredArg("player", "Target player", ArgTypes.STRING);
private final RequiredArg<String> reasonArg = withRequiredArg("reason", "Reason", ArgTypes.STRING);
private final RequiredArg<String> reasonArg = withRequiredArg("reason", "Reason", ArgTypes.GREEDY_STRING);
public Warn(@Nonnull PunishmentService p, @Nonnull StaffChatService c, @Nonnull PlayerResolver r) {
super("warn", "Warn a player");
@@ -4,6 +4,7 @@ public final class StaffConfig {
public ChatSection chat = new ChatSection();
public TableSection tables = new TableSection();
public PresenceSection presence = new PresenceSection();
public static final class ChatSection {
public String channel = "staff.chat";
@@ -14,4 +15,17 @@ public final class StaffConfig {
public String punishments = "staff_punishments";
public String players_seen = "staff_players_seen";
}
public static final class PresenceSection {
/**
* When true, announce on staff chat when a staff member joins or leaves the network.
* Server transfers are suppressed (counted via NetworkCore PlayerPresence).
*/
public boolean announce_staff_join_leave = true;
/**
* Delay before declaring a disconnect a "left network" event. If the player reconnects
* anywhere within this window, the leave announcement is suppressed.
*/
public int leave_suppression_seconds = 10;
}
}
@@ -30,6 +30,7 @@ public final class StaffConfigLoader {
if (parsed == null) parsed = new StaffConfig();
if (parsed.chat == null) parsed.chat = new StaffConfig.ChatSection();
if (parsed.tables == null) parsed.tables = new StaffConfig.TableSection();
if (parsed.presence == null) parsed.presence = new StaffConfig.PresenceSection();
return parsed;
}
}
@@ -94,6 +94,19 @@ public final class PunishmentRepository {
return out;
}
@Nonnull
public List<Punishment> findRecent(int limit) throws SQLException {
String sql = "SELECT * FROM " + table + " ORDER BY issued_at DESC LIMIT ?";
List<Punishment> 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;
}
public boolean revoke(long id, @Nonnull UUID revokedBy, @Nonnull String revokedByName, @Nullable String reason, long revokedAt) throws SQLException {
String sql = "UPDATE " + table + " SET active = 0, revoked_at = ?, revoked_by_uuid = ?, revoked_by_name = ?, revoke_reason = ? WHERE id = ? AND active = 1";
try (Connection c = ds.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
@@ -0,0 +1,102 @@
package net.kewwbec.staff.listener;
import com.hypixel.hytale.logger.HytaleLogger;
import com.hypixel.hytale.server.core.HytaleServer;
import com.hypixel.hytale.server.core.event.events.player.PlayerConnectEvent;
import com.hypixel.hytale.server.core.event.events.player.PlayerDisconnectEvent;
import com.hypixel.hytale.server.core.universe.PlayerRef;
import net.kewwbec.networkcore.api.PlayerLocation;
import net.kewwbec.networkcore.api.PlayerPresence;
import net.kewwbec.staff.StaffPermissionsNodes;
import net.kewwbec.staff.service.StaffChatService;
import net.kewwbec.staff.util.StaffPermissions;
import javax.annotation.Nonnull;
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;
/**
* Announces on staff chat when a staff member joins or leaves the **network** (not just this server).
*
* Join: fires immediately when this server sees a staff member's PlayerConnectEvent AND PlayerPresence
* has no other record of them on the network. (If they were already on another server before
* transferring here, suppressed.)
*
* Leave: on PlayerDisconnectEvent, schedule a check N seconds later. If the player is still gone
* from PlayerPresence at that point, announce the leave. If they reconnected anywhere in that
* window, suppress.
*/
public final class StaffPresenceListener {
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
private final StaffChatService staffChat;
private final StaffPermissions permissions;
private final PlayerPresence presence;
private final long leaveSuppressionSeconds;
private final Map<UUID, ScheduledFuture<?>> pendingLeaveChecks = new ConcurrentHashMap<>();
public StaffPresenceListener(@Nonnull StaffChatService staffChat,
@Nonnull StaffPermissions permissions,
@Nonnull PlayerPresence presence,
long leaveSuppressionSeconds) {
this.staffChat = staffChat;
this.permissions = permissions;
this.presence = presence;
this.leaveSuppressionSeconds = leaveSuppressionSeconds;
}
public void onConnect(@Nonnull PlayerConnectEvent event) {
PlayerRef ref = event.getPlayerRef();
if (ref == null) return;
UUID uuid = ref.getUuid();
ScheduledFuture<?> pending = pendingLeaveChecks.remove(uuid);
if (pending != null) {
pending.cancel(false);
return;
}
if (!permissions.has(ref, StaffPermissionsNodes.CHAT)) return;
PlayerLocation existing = presence.getLocation(uuid);
if (existing != null) {
return;
}
staffChat.announce("[+] " + ref.getUsername() + " joined the network.");
}
public void onDisconnect(@Nonnull PlayerDisconnectEvent event) {
PlayerRef ref = event.getPlayerRef();
if (ref == null) return;
if (!permissions.has(ref, StaffPermissionsNodes.CHAT)) return;
UUID uuid = ref.getUuid();
String name = ref.getUsername();
ScheduledFuture<?> prev = pendingLeaveChecks.remove(uuid);
if (prev != null) prev.cancel(false);
ScheduledFuture<?> task = HytaleServer.SCHEDULED_EXECUTOR.schedule(() -> {
try {
pendingLeaveChecks.remove(uuid);
if (presence.getLocation(uuid) != null) return;
staffChat.announce("[-] " + name + " left the network.");
} catch (RuntimeException e) {
LOGGER.at(Level.WARNING).log("Staff leave check failed for %s: %s", name, e.getMessage());
}
}, leaveSuppressionSeconds, TimeUnit.SECONDS);
pendingLeaveChecks.put(uuid, task);
}
public void stop() {
for (ScheduledFuture<?> f : pendingLeaveChecks.values()) {
f.cancel(false);
}
pendingLeaveChecks.clear();
}
}
@@ -73,7 +73,7 @@ public final class HistoryPage {
<div class="container" data-hyui-title="History: %s">
<div class="container-contents">
%s
<button class="back-button" id="backBtn">Back</button>
<button id="backBtn">Back</button>
</div>
</div>
</div>
@@ -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 """
<div class="page-overlay">
<div class="decorated-container" data-hyui-title="%s" style="anchor-width: %d; anchor-height: %d;">
<div class="container-contents" style="layout-mode: topscrolling;">
%s
</div>
</div>
</div>
""".formatted(escape(title), width, height, contentsHtml);
}
@Nonnull
public static String escape(@Nonnull String s) {
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;");
}
}
@@ -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<PlayerSeen> 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<Punishment> 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<Punishment> 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 ? "<button id=\"unbanBtn\">Unban</button>" : "";
String unmuteBtn = hasActiveMute ? "<button id=\"unmuteBtn\">Unmute</button>" : "";
String html = """
<div class="page-overlay">
<div class="container" data-hyui-title="%s">
<div class="container-contents">
<p><b>%s</b></p>
<p>UUID: %s</p>
<p>%s</p>
<p>%s</p>
<p>Punishment history: %d total</p>
<p>&nbsp;</p>
<p><b>Active</b></p>
<p>%s</p>
<p>%s</p>
<button id="banBtn">Ban</button>
<button id="tempbanBtn">TempBan</button>
<button id="muteBtn">Mute</button>
<button id="tempmuteBtn">TempMute</button>
<button id="kickBtn">Kick</button>
<button id="warnBtn">Warn</button>
<button id="unbanBtn">Unban</button>
<button id="unmuteBtn">Unmute</button>
<button id="historyBtn">History</button>
<button class="back-button" id="backBtn">Back</button>
<p>&nbsp;</p>
<p><b>Apply punishment</b></p>
<button id="banBtn">Ban (permanent)</button>
<button id="tempbanBtn">TempBan (with duration)</button>
<button id="muteBtn">Mute (permanent)</button>
<button id="tempmuteBtn">TempMute (with duration)</button>
<button id="kickBtn">Kick (one-time)</button>
<button id="warnBtn">Warn (record only)</button>
<p>&nbsp;</p>
<p><b>Lift</b></p>
%s
%s
<p>&nbsp;</p>
<p><b>Records</b></p>
<button id="historyBtn">View full history</button>
<p>&nbsp;</p>
<button id="backBtn">Back to Lookup</button>
</div>
</div>
</div>
""".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))
@@ -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<UUID, String> hits = buildHits(query);
Map<UUID, Row> hits = buildHits(query);
StringBuilder rows = new StringBuilder();
int i = 0;
for (Map.Entry<UUID, String> e : hits.entrySet()) {
rows.append("<button id=\"row")
.append(i)
.append("\">")
.append(escape(e.getValue()))
.append("</button>");
for (Row r : hits.values()) {
rows.append("<p>").append(escape(r.label())).append("</p>");
rows.append("<button id=\"row").append(i).append("\">Open ").append(escape(r.name())).append("</button>");
i++;
}
String resultsHtml = rows.length() == 0
? "<p>" + (query.isBlank() ? "Type a name and click Search." : "No matches for '" + escape(query) + "'.") + "</p>"
? "<p>" + (query.isBlank() ? "Type a name and click Search. Empty search lists everyone online." : "No matches for '" + escape(query) + "'.") + "</p>"
: rows.toString();
String html = """
<div class="page-overlay">
<div class="container" data-hyui-title="Player Lookup">
<div class="container-contents">
<p>Search by username. Online players first, then offline players from history.</p>
<input type="text" id="searchInput" placeholder="Player name" value="%s" />
<button id="searchBtn">Search</button>
<button class="back-button" id="backBtn">Back</button>
<p>%d result(s)</p>
<div>%s</div>
<button id="clearBtn">List all online</button>
<p>&nbsp;</p>
<p><b>Results: %d</b></p>
%s
<p>&nbsp;</p>
<button id="backBtn">Back to Hub</button>
</div>
</div>
</div>
@@ -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<UUID, String> 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<UUID, String> buildHits(@Nonnull String q) {
Map<UUID, String> out = new LinkedHashMap<>();
private Map<UUID, Row> buildHits(@Nonnull String q) {
Map<UUID, Row> 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;");
return s == null ? "" : s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;");
}
private record Row(@Nonnull UUID uuid, @Nonnull String name, @Nonnull String label) {}
}
@@ -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()
? """
<label>Duration (e.g. 30m, 2h, 7d):</label>
<input type="text" id="durationInput" placeholder="Duration" value="%s" />
""".formatted(escape(duration))
: "";
StringBuilder durationBlock = new StringBuilder();
if (type.isTimed()) {
durationBlock.append("<p><b>Duration</b></p>");
durationBlock.append("<p>Click a preset, or type a custom value (e.g. 30s, 12h, 2w).</p>");
for (int i = 0; i < DURATION_PRESETS.length; i++) {
durationBlock.append("<button id=\"dPreset").append(i).append("\">")
.append(DURATION_PRESETS[i]).append("</button>");
}
durationBlock.append("<input type=\"text\" id=\"durationInput\" placeholder=\"Custom duration\" value=\"")
.append(escape(duration)).append("\" />");
}
String errorLine = error.isBlank() ? "" : "<p>" + escape(error) + "</p>";
StringBuilder reasonPresets = new StringBuilder();
reasonPresets.append("<p><b>Reason</b></p>");
reasonPresets.append("<p>Pick a preset to fill the field, or type your own.</p>");
for (int i = 0; i < REASON_PRESETS.length; i++) {
reasonPresets.append("<button id=\"rPreset").append(i).append("\">")
.append(escape(REASON_PRESETS[i])).append("</button>");
}
reasonPresets.append("<input type=\"text\" id=\"reasonInput\" placeholder=\"Reason\" value=\"")
.append(escape(reason)).append("\" />");
String errorLine = error.isBlank() ? "" : "<p>! " + escape(error) + "</p>";
String html = """
<div class="page-overlay">
<div class="container" data-hyui-title="Issue %s">
<div class="container-contents">
<p>Target: %s</p>
<p>Target: <b>%s</b></p>
<p>&nbsp;</p>
%s
<label>Reason:</label>
<input type="text" id="reasonInput" placeholder="Reason" value="%s" />
<p>&nbsp;</p>
%s
<button id="submitBtn">Confirm</button>
<button class="back-button" id="cancelBtn">Cancel</button>
<p>&nbsp;</p>
%s
<button id="submitBtn">Confirm %s</button>
<button id="cancelBtn">Cancel</button>
</div>
</div>
</div>
""".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() : "";
@@ -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<Punishment> recent = ctx.punishmentRepo.findRecent(PAGE_SIZE);
if (recent.isEmpty()) {
body.append("<p>No punishments on record yet.</p>");
} 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("<p>#")
.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("</p>");
}
}
} catch (SQLException e) {
((HytaleLogger.Api) LOGGER.at(Level.WARNING).withCause(e)).log("Recent punishments query failed");
body.append("<p>DB error: ").append(escape(e.getMessage())).append("</p>");
}
String html = """
<div class="page-overlay">
<div class="container" data-hyui-title="Recent Punishments">
<div class="container-contents">
%s
<button id="backBtn">Back</button>
</div>
</div>
</div>
""".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<EntityStore> store) {
page.open(store);
}
private void openInStore(@Nonnull Consumer<Store<EntityStore>> action) {
Ref<EntityStore> ref = playerRef.getReference();
if (ref == null) return;
action.accept(ref.getStore());
}
@Nonnull
private static String escape(@Nonnull String s) {
return s == null ? "" : s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;");
}
}
@@ -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 = """
<div class="page-overlay">
<div class="container" data-hyui-title="Staff Commands">
<div class="container-contents">
<p><b>Punishments</b></p>
<p>/ban &lt;player&gt; &lt;reason...&gt;</p>
<p>/tempban &lt;player&gt; &lt;duration&gt; &lt;reason...&gt;</p>
<p>/mute &lt;player&gt; &lt;reason...&gt;</p>
<p>/tempmute &lt;player&gt; &lt;duration&gt; &lt;reason...&gt;</p>
<p>/kick &lt;player&gt; &lt;reason...&gt;</p>
<p>/warn &lt;player&gt; &lt;reason...&gt;</p>
<p>/unban &lt;player&gt;</p>
<p>/unmute &lt;player&gt;</p>
<p>Durations: 30s, 5m, 2h, 7d, 1w</p>
<p><b>Lookup</b></p>
<p>/lookup &lt;player&gt; - active punishments + presence</p>
<p>/history &lt;player&gt; - full punishment history</p>
<p><b>Staff chat</b></p>
<p>/sc - toggle staff chat mode (your chat goes to staff)</p>
<p><b>UI</b></p>
<p>/staffui - open this hub</p>
<button id="backBtn">Back</button>
</div>
</div>
</div>
""";
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<EntityStore> store) {
page.open(store);
}
private void openInStore(@Nonnull Consumer<Store<EntityStore>> action) {
Ref<EntityStore> ref = playerRef.getReference();
if (ref == null) return;
action.accept(ref.getStore());
}
}
@@ -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("""
<div class="page-overlay">
<div class="container" data-hyui-title="Staff Hub">
<div class="container-contents">
<p>Pick an action. Press ESC to close.</p>
<button id="lookupBtn">Player Lookup</button>
</div>
</div>
</div>
""");
wire();
}
private void wire() {
page.getById("lookupBtn", ButtonBuilder.class).ifPresent(b ->
b.addEventListener(CustomUIEventBindingType.Activating, e -> openLookup()));
int onlineHere = onlineCount();
String body = """
<p>Signed in as %s.</p>
<p>%d player(s) online on this server.</p>
<p>&nbsp;</p>
<p><b>Players</b></p>
<button id="lookupBtn">Lookup - search, view, and act on any player</button>
<p>&nbsp;</p>
<p><b>History</b></p>
<button id="recentBtn">Recent Punishments - last 50 actions network-wide</button>
<p>&nbsp;</p>
<p><b>Help</b></p>
<button id="helpBtn">Commands Reference - every staff command</button>
<p>&nbsp;</p>
<button id="closeBtn" class="secondary-button">Close</button>
""".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<EntityStore> 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<Store<EntityStore>> action) {
Ref<EntityStore> 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;");
}
}