From 4f4f731bc79858412ddd28014e2c4d1d54f74628 Mon Sep 17 00:00:00 2001 From: ehko Date: Tue, 26 May 2026 00:59:39 -0400 Subject: [PATCH] init --- .gitignore | 30 ++ README.md | 41 +++ docs/01_Overview.md | 65 ++++ docs/02_Configuration.md | 61 ++++ docs/03_Commands.md | 71 ++++ docs/04_Schema.md | 94 +++++ docs/05_Cross_Server_Propagation.md | 57 +++ docs/06_Operations.md | 77 ++++ docs/README.md | 8 + pom.xml | 51 +++ .../java/net/kewwbec/staff/StaffPlugin.java | 141 ++++++++ .../staff/command/chat/StaffChatCommand.java | 48 +++ .../staff/command/lookup/HistoryCommand.java | 97 ++++++ .../staff/command/lookup/LookupCommand.java | 114 ++++++ .../command/punish/PunishmentCommands.java | 328 ++++++++++++++++++ .../net/kewwbec/staff/config/StaffConfig.java | 26 ++ .../staff/config/StaffConfigLoader.java | 36 ++ .../staff/db/PlayerSeenRepository.java | 95 +++++ .../staff/db/PunishmentRepository.java | 149 ++++++++ .../net/kewwbec/staff/db/SchemaBootstrap.java | 64 ++++ .../kewwbec/staff/listener/BanEnforcer.java | 35 ++ .../kewwbec/staff/listener/ChatEnforcer.java | 32 ++ .../staff/listener/PlayerSeenTracker.java | 57 +++ .../net/kewwbec/staff/model/PlayerSeen.java | 15 + .../net/kewwbec/staff/model/Punishment.java | 33 ++ .../kewwbec/staff/model/PunishmentType.java | 30 ++ .../net/kewwbec/staff/service/MuteCache.java | 37 ++ .../staff/service/PunishmentBroadcast.java | 23 ++ .../staff/service/PunishmentService.java | 225 ++++++++++++ .../staff/service/StaffChatPayload.java | 10 + .../staff/service/StaffChatService.java | 87 +++++ .../kewwbec/staff/util/DurationParser.java | 51 +++ .../kewwbec/staff/util/PlayerResolver.java | 66 ++++ .../kewwbec/staff/util/StaffPermissions.java | 46 +++ src/main/resources/manifest.json | 17 + 35 files changed, 2417 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/01_Overview.md create mode 100644 docs/02_Configuration.md create mode 100644 docs/03_Commands.md create mode 100644 docs/04_Schema.md create mode 100644 docs/05_Cross_Server_Propagation.md create mode 100644 docs/06_Operations.md create mode 100644 docs/README.md create mode 100644 pom.xml create mode 100644 src/main/java/net/kewwbec/staff/StaffPlugin.java create mode 100644 src/main/java/net/kewwbec/staff/command/chat/StaffChatCommand.java create mode 100644 src/main/java/net/kewwbec/staff/command/lookup/HistoryCommand.java create mode 100644 src/main/java/net/kewwbec/staff/command/lookup/LookupCommand.java create mode 100644 src/main/java/net/kewwbec/staff/command/punish/PunishmentCommands.java create mode 100644 src/main/java/net/kewwbec/staff/config/StaffConfig.java create mode 100644 src/main/java/net/kewwbec/staff/config/StaffConfigLoader.java create mode 100644 src/main/java/net/kewwbec/staff/db/PlayerSeenRepository.java create mode 100644 src/main/java/net/kewwbec/staff/db/PunishmentRepository.java create mode 100644 src/main/java/net/kewwbec/staff/db/SchemaBootstrap.java create mode 100644 src/main/java/net/kewwbec/staff/listener/BanEnforcer.java create mode 100644 src/main/java/net/kewwbec/staff/listener/ChatEnforcer.java create mode 100644 src/main/java/net/kewwbec/staff/listener/PlayerSeenTracker.java create mode 100644 src/main/java/net/kewwbec/staff/model/PlayerSeen.java create mode 100644 src/main/java/net/kewwbec/staff/model/Punishment.java create mode 100644 src/main/java/net/kewwbec/staff/model/PunishmentType.java create mode 100644 src/main/java/net/kewwbec/staff/service/MuteCache.java create mode 100644 src/main/java/net/kewwbec/staff/service/PunishmentBroadcast.java create mode 100644 src/main/java/net/kewwbec/staff/service/PunishmentService.java create mode 100644 src/main/java/net/kewwbec/staff/service/StaffChatPayload.java create mode 100644 src/main/java/net/kewwbec/staff/service/StaffChatService.java create mode 100644 src/main/java/net/kewwbec/staff/util/DurationParser.java create mode 100644 src/main/java/net/kewwbec/staff/util/PlayerResolver.java create mode 100644 src/main/java/net/kewwbec/staff/util/StaffPermissions.java create mode 100644 src/main/resources/manifest.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8254f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Build output +target/ +out/ +build/ +*.class +*.jar +dependency-reduced-pom.xml + +# IDE +.idea/ +*.iml +*.iws +*.ipr +.vscode/ +.project +.classpath +.settings/ + +# Logs +*.log +logs/ + +# OS +.DS_Store +Thumbs.db + +# Local config / secrets +config.json.local +*.env +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..8617130 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# Staff + +Staff tools for KweebecNet: punishments, history, cross-server staff chat, player lookup. + +## What it does + +- **Punishments**: ban, tempban, mute, tempmute, kick, warn. All persist to MySQL. Bans + mutes propagate across all servers in the network within milliseconds (via NetworkCore's MessageBus). +- **History**: every action a player has ever received is queryable with `/history `. +- **Staff chat**: `/sc ` broadcasts to every staff member online on any server. +- **Player lookup**: `/lookup ` shows UUID, online status, current/last server, active punishments. + +## Requires + +- **NetworkCore** with `mysql.enabled: true` and a healthy MySQL connection +- A working `network.auth_token` configured the same way as for NetworkCore (since this plugin uses the cross-server bus) +- The built-in Hytale `PermissionsModule` populated with at least one of the configured staff groups (defaults: `OP`, `ADMIN`, `MOD`, `HELPER`) + +If MySQL isn't configured in NetworkCore, Staff refuses to start and logs a clear error. Fix NetworkCore first, then restart. + +## Read before changing + +See [docs/](docs/) for the full breakdown: + +- [01_Overview.md](docs/01_Overview.md) - what's here and how the pieces fit +- [02_Configuration.md](docs/02_Configuration.md) - `config.json` fields, staff group setup +- [03_Commands.md](docs/03_Commands.md) - every command, syntax, behavior +- [04_Schema.md](docs/04_Schema.md) - MySQL tables, what's stored +- [05_Cross_Server_Propagation.md](docs/05_Cross_Server_Propagation.md) - how a ban on lobby-1 reaches arena-3 +- [06_Operations.md](docs/06_Operations.md) - common ops scenarios, debugging tips + +## Why this is critical + +This plugin enforces who can be on the network. Bugs here mean: + +- A banned player gets back in (the network looks compromised to your community) +- A staff member loses access (the network looks broken to staff) +- A mute leaks across worlds incorrectly (chat gets weird) + +Treat changes to enforcement code (`BanEnforcer`, `ChatEnforcer`, `PunishmentService`) the same way you'd treat changes to NetworkCore - read the docs and ask a higher-up before merging. + +The non-enforcement pieces (lookup, history, staff chat formatting) are lower-stakes; you can iterate on those more freely. diff --git a/docs/01_Overview.md b/docs/01_Overview.md new file mode 100644 index 0000000..61119f5 --- /dev/null +++ b/docs/01_Overview.md @@ -0,0 +1,65 @@ +# Overview + +## Architecture in one diagram + +``` +StaffPlugin (entry) + +-- StaffPermissions wraps Hytale PermissionsModule, checks groups from config + +-- PunishmentService issue / revoke / lookup, broadcasts on MessageBus + | +-- PunishmentRepository SQL CRUD + | +-- MuteCache in-memory map of active mutes for online players + +-- StaffChatService cross-server staff chat over MessageBus + +-- PlayerSeenTracker writes to players_seen on join/leave + | +-- PlayerSeenRepository SQL CRUD + +-- BanEnforcer cancels PlayerSetupConnectEvent if banned + +-- ChatEnforcer cancels PlayerChatEvent if muted +``` + +External dependencies: + +- **NetworkCore.getDatabase()** - MySQL pool (HikariCP). Required. +- **NetworkCore.getMessageBus()** - Redis pub/sub. Required. +- **NetworkCore.getServerId()** - identifies which server an action was taken on. +- **PermissionsModule.get()** - Hytale's built-in group/permission API. + +## What lives where + +| File | Responsibility | +|---|---| +| [StaffPlugin.java](../src/main/java/net/kewwbec/staff/StaffPlugin.java) | Plugin entry. Reads NetworkCore services, bootstraps schema, registers everything. | +| [config/StaffConfig.java](../src/main/java/net/kewwbec/staff/config/StaffConfig.java) | Default config shape. | +| [util/StaffPermissions.java](../src/main/java/net/kewwbec/staff/util/StaffPermissions.java) | `isStaff(uuid)` / `isAdmin(uuid)` checks. | +| [util/PlayerResolver.java](../src/main/java/net/kewwbec/staff/util/PlayerResolver.java) | Name -> UUID, looks at online players first, then players_seen. | +| [util/DurationParser.java](../src/main/java/net/kewwbec/staff/util/DurationParser.java) | Parse "30m", "2h" etc.; format milliseconds back to human strings. | +| [model/Punishment.java](../src/main/java/net/kewwbec/staff/model/Punishment.java), [PunishmentType.java](../src/main/java/net/kewwbec/staff/model/PunishmentType.java), [PlayerSeen.java](../src/main/java/net/kewwbec/staff/model/PlayerSeen.java) | Record types. | +| [db/SchemaBootstrap.java](../src/main/java/net/kewwbec/staff/db/SchemaBootstrap.java) | `CREATE TABLE IF NOT EXISTS` for both tables. Run on `start()`. | +| [db/PunishmentRepository.java](../src/main/java/net/kewwbec/staff/db/PunishmentRepository.java) | SQL for punishments. | +| [db/PlayerSeenRepository.java](../src/main/java/net/kewwbec/staff/db/PlayerSeenRepository.java) | SQL for player presence. | +| [service/PunishmentService.java](../src/main/java/net/kewwbec/staff/service/PunishmentService.java) | Issuing/revoking + cross-server broadcast + applying remote actions locally. | +| [service/MuteCache.java](../src/main/java/net/kewwbec/staff/service/MuteCache.java) | Per-server hot cache of active mutes for online players. | +| [service/StaffChatService.java](../src/main/java/net/kewwbec/staff/service/StaffChatService.java) | Cross-server staff chat over MessageBus. | +| [service/PunishmentBroadcast.java](../src/main/java/net/kewwbec/staff/service/PunishmentBroadcast.java), [StaffChatPayload.java](../src/main/java/net/kewwbec/staff/service/StaffChatPayload.java) | Wire payloads. | +| [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. | +| [listener/PlayerSeenTracker.java](../src/main/java/net/kewwbec/staff/listener/PlayerSeenTracker.java) | Connect/disconnect -> update players_seen, load/clear mute cache. | +| [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`. | +| [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`. | + +## Why the design is this shape + +- **One service per concern.** Punishments and staff chat are independent. Each owns its own MessageBus subscription and can be modified without touching the other. +- **Repositories return models, services orchestrate.** Repositories never call services; services call repositories. This keeps SQL out of the punishment-issuing logic. +- **MuteCache is per-server, not network-wide.** Mute state is loaded on join for that one player and cleared on leave. Network-wide propagation happens via the MessageBus, which updates other servers' caches when their player base intersects. +- **BanEnforcer talks to MySQL on connect.** No cache. Connects are infrequent enough that a single indexed query is fine; the data is authoritative and we don't want stale-cache surprises letting a banned player in. +- **Permission checks live in the command, not in services.** That way a future console source can call the same service without re-checking permissions. + +## Lifecycle + +Standard Hytale plugin lifecycle: + +- `setup()` - load config, build `StaffPermissions` and `MuteCache`. +- `start()` - require NetworkCore's database + bus, bootstrap schema, build services and repositories, mark this server's stale online flags as offline (crash recovery), register events and commands. +- `shutdown()` - stop service subscriptions. The database pool is owned by NetworkCore and closes on its own shutdown. + +If MySQL isn't healthy at `start()`, this plugin logs a severe error and returns. The server keeps running; this plugin just does nothing. Restart once MySQL is back. diff --git a/docs/02_Configuration.md b/docs/02_Configuration.md new file mode 100644 index 0000000..06d5724 --- /dev/null +++ b/docs/02_Configuration.md @@ -0,0 +1,61 @@ +# Configuration + +## Location + +`/config.json`. Written with defaults on first boot. + +## Full config + +```json +{ + "permissions": { + "staff_groups": ["OP", "ADMIN", "MOD", "HELPER"], + "admin_groups": ["OP", "ADMIN"] + }, + "chat": { + "channel": "staff.chat", + "prefix": "[Staff]" + }, + "tables": { + "punishments": "staff_punishments", + "players_seen": "staff_players_seen" + } +} +``` + +## Fields + +### permissions + +| Field | Default | Meaning | +|---|---|---| +| `staff_groups` | `["OP", "ADMIN", "MOD", "HELPER"]` | Any Hytale group name in this list grants access to staff commands. | +| `admin_groups` | `["OP", "ADMIN"]` | Subset that the plugin treats as "admin". v1 doesn't gate anything on this, but it's available for future features that want to require admin (e.g. unban after a permaban). | + +Group names come from `PermissionsModule.get().getGroupsForUser(uuid)`. Set up your groups in the Hytale server's own configuration first; this plugin just reads them. + +### chat + +| Field | Default | Meaning | +|---|---|---| +| `channel` | `"staff.chat"` | MessageBus channel used for cross-server staff chat. Don't share this with other plugins. | +| `prefix` | `"[Staff]"` | Visual prefix on every staff chat line. | + +### tables + +| Field | Default | Meaning | +|---|---|---| +| `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. | + +## Setting up staff groups + +In your Hytale server config, define groups and assign players: + +1. Make sure the group names you assign match one of `staff_groups` exactly. The check is case-sensitive (groups in Hytale tend to be uppercase, e.g. `OP`). +2. After assigning, the player needs to relog for the change to take effect (`PermissionsModule.get().getGroupsForUser` is queried on each command, but Hytale generally requires a reconnect for group changes to apply on the player side). +3. Test with `/lookup ` from a staff account. If you see the lookup output, you have staff perms. + +## Environment variables + +None specific to Staff. The relevant secrets (`MYSQL_PASSWORD`, `NETWORK_AUTH_TOKEN`) live in NetworkCore's env. diff --git a/docs/03_Commands.md b/docs/03_Commands.md new file mode 100644 index 0000000..1acc864 --- /dev/null +++ b/docs/03_Commands.md @@ -0,0 +1,71 @@ +# Commands + +All commands require the caller to be in one of the `staff_groups` from config. Player-only (no console support yet). + +## Punishments + +### /ban <player> <reason> + +Permanent ban. Active across all servers in the network within a few hundred ms. Online target is disconnected immediately with the reason. + +### /tempban <player> <duration> <reason> + +Time-limited ban. Duration syntax: `30s`, `5m`, `2h`, `7d`, `1w`. Expires automatically (the active-ban query filters by `expires_at`). + +### /mute <player> <reason> + +Permanent mute. Cancels `PlayerChatEvent` for the target on every server. Loaded into a per-server in-memory cache on the target's connect so chat enforcement doesn't hit MySQL on every message. + +### /tempmute <player> <duration> <reason> + +Time-limited mute. Same duration syntax as tempban. + +### /kick <player> <reason> + +One-shot disconnect. Recorded in history but doesn't persist beyond that (the target can rejoin immediately). + +### /warn <player> <reason> + +Recorded in history and shown to the target as a message if they're online. No connection or chat impact. + +### /unban <player> + +Revokes any active ban or tempban for the player. Marks the punishment row as `active=0`, fills `revoked_at` / `revoked_by_*`. Original row stays in history. + +### /unmute <player> + +Same as unban but for mutes. Also clears the entry from the mute cache on any server where the player is online. + +## Staff chat + +### /sc <message> + +Broadcasts on the cross-server staff chat channel. Visible only to other players whose `getGroupsForUser` overlaps with `staff_groups`. + +System events (a ban being issued, an unban) also post a line on this channel automatically. + +## Lookup + +### /lookup <player> + +Shows: +- Player's last-known name and UUID +- Online status (and which server they're on, if any) +- First-seen and last-seen timestamps +- Any active ban +- Any active mute + +If the player has never connected, only the name/UUID lookup happens via online players and may return nothing. + +### /history <player> + +Last 20 punishment entries for the player, most recent first. Each entry shows id, state (`ACTIVE` / `EXPIRED` / `REVOKED`), type, who issued it, when, and the reason. + +## Common error cases + +| Error message | Cause | +|---|---| +| "You do not have permission to use this command." | Caller isn't in any of `staff_groups`. | +| "No player found with that name (online or in history)." | Name doesn't match any online player or any name in `staff_players_seen`. | +| "Database error: ..." | MySQL query failed. Check NetworkCore connection (`/networkcore status`). | +| "Bad duration. Use 30s, 5m, 2h, 7d, 1w." | Duration string didn't parse. Last char must be `s`/`m`/`h`/`d`/`w`, prefix must be a positive integer. | diff --git a/docs/04_Schema.md b/docs/04_Schema.md new file mode 100644 index 0000000..8ae1d2f --- /dev/null +++ b/docs/04_Schema.md @@ -0,0 +1,94 @@ +# Schema + +Two MySQL tables, created automatically on `start()` via `CREATE TABLE IF NOT EXISTS`. Both prefixed `staff_` so they don't collide with other plugins. + +## staff_punishments + +```sql +CREATE TABLE IF NOT EXISTS staff_punishments ( + id BIGINT NOT NULL AUTO_INCREMENT, + target_uuid CHAR(36) NOT NULL, + target_name VARCHAR(64) NOT NULL, + type VARCHAR(16) NOT NULL, -- BAN, TEMPBAN, MUTE, TEMPMUTE, KICK, WARN + reason VARCHAR(512) NOT NULL, + issued_by_uuid CHAR(36) NOT NULL, + issued_by_name VARCHAR(64) NOT NULL, + issued_at BIGINT NOT NULL, -- epoch millis + expires_at BIGINT NULL, -- epoch millis; NULL = permanent + issued_on_server VARCHAR(64) NOT NULL, + active TINYINT(1) NOT NULL DEFAULT 1, + revoked_at BIGINT NULL, + revoked_by_uuid CHAR(36) NULL, + revoked_by_name VARCHAR(64) NULL, + revoke_reason VARCHAR(512) NULL, + PRIMARY KEY (id), + KEY idx_target_uuid (target_uuid), + KEY idx_target_active (target_uuid, type, active), + KEY idx_issued_at (issued_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 +``` + +### Notes + +- Rows are **never deleted**. Revokes flip `active=0` and fill the `revoked_*` columns. Wipe via SQL only if you really mean to lose history. +- `target_name` is captured at issue time and never updated. If a player renames, history shows the old name; lookup by UUID is canonical. +- `issued_on_server` is the server id that issued the action. Useful for "who issued this from where" forensics; not used for enforcement. + +### Common queries + +```sql +-- Active ban for a player +SELECT * FROM staff_punishments +WHERE target_uuid = ? AND type IN ('BAN', 'TEMPBAN') + AND active = 1 AND revoked_at IS NULL + AND (expires_at IS NULL OR expires_at > UNIX_TIMESTAMP() * 1000) +ORDER BY issued_at DESC LIMIT 1; + +-- All actions a moderator has taken in the last 7 days +SELECT * FROM staff_punishments +WHERE issued_by_uuid = ? + AND issued_at > (UNIX_TIMESTAMP() * 1000) - 7 * 86400000 +ORDER BY issued_at DESC; + +-- Most-punished players in the last month +SELECT target_name, COUNT(*) c +FROM staff_punishments +WHERE issued_at > (UNIX_TIMESTAMP() * 1000) - 30 * 86400000 +GROUP BY target_uuid, target_name +ORDER BY c DESC +LIMIT 20; +``` + +## staff_players_seen + +```sql +CREATE TABLE IF NOT EXISTS staff_players_seen ( + uuid CHAR(36) NOT NULL, + last_name VARCHAR(64) NOT NULL, + first_seen BIGINT NOT NULL, + last_seen BIGINT NOT NULL, + last_server_id VARCHAR(64) NULL, + online TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (uuid), + KEY idx_last_name (last_name), + KEY idx_last_seen (last_seen) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 +``` + +### Notes + +- One row per player UUID. Upserted on join. +- `online` is best-effort: a hard server crash leaves the row stuck at `online=1` until that server boots again and runs `markServerOffline(serverId)` (which Staff does in `start()`). +- `last_server_id` is the last server the player joined. If `online=1` it's the server they're on now. + +## Migrations + +There aren't any yet. When a schema change is needed: + +1. Add a `migrations/00X_what.sql` file +2. Add a tiny runner in `SchemaBootstrap` that: + - Reads applied migrations from a `staff_schema_version` table (create on first run) + - Applies the next pending one, commits, records it +3. Ship together. Don't backport schema changes to old plugin versions without a migration. + +Don't drop into Flyway/Liquibase until you have at least 3 real migrations - they earn their weight on real projects, not on the second migration. diff --git a/docs/05_Cross_Server_Propagation.md b/docs/05_Cross_Server_Propagation.md new file mode 100644 index 0000000..d58b0a5 --- /dev/null +++ b/docs/05_Cross_Server_Propagation.md @@ -0,0 +1,57 @@ +# Cross-Server Propagation + +How a `/ban` on one server affects players on all other servers. + +## The path of a ban + +1. **Staff types `/ban Bob griefing` on lobby-1.** +2. `PunishmentCommands.Ban.execute` runs: + - Calls `PunishmentService.issue(...)`. +3. `PunishmentService.issue(...)`: + - INSERTs the row into `staff_punishments` (active=1, revoked_at=null). + - Calls `applyLocally(saved)` - if Bob is online on lobby-1, kicks him. + - Calls `publishIssued(saved)` - sends a `PunishmentBroadcast` on MessageBus channel `staff.punishments`. +4. **arena-3 receives the broadcast** on its `staff.punishments` subscription. +5. `PunishmentService.onRemote` runs: + - Ignores its own broadcasts (`issuedOnServer == self`). + - Reconstructs a transient `Punishment` from the payload. + - Calls `applyLocally(p)` - same logic as the issuer used. + - On arena-3 specifically: if Bob is currently online there, he gets kicked. If he's not, nothing happens at this stage. +6. **Bob tries to reconnect somewhere later.** The receiving server runs `BanEnforcer.onSetupConnect`, which queries `staff_punishments` directly. The active ban is found, the connect is cancelled with the reason. + +## Why both paths matter + +- **The broadcast** handles "Bob is online right now somewhere else, kick him *now*". +- **The DB query on connect** handles "Bob tries to join 30 minutes after his ban; nobody's holding state for him in memory." + +If you only had the broadcast, a player who reconnected after the broadcast fired would walk back in until the next broadcast. If you only had the DB query, a banned player who's already in-game stays in-game until they disconnect. + +## Mutes + +Same flow, except: +- `applyLocally` for a mute updates the local `MuteCache` (no kick). +- `ChatEnforcer` reads from `MuteCache` on every `PlayerChatEvent` - no DB query per chat message. +- On player connect (any server), `PlayerSeenTracker.onConnect` calls `PunishmentService.loadMuteIntoCache(uuid)` which queries `staff_punishments` and populates the cache. This handles "player joins after a mute was issued while they were offline." + +## Unban / unmute + +`PunishmentService.revoke` UPDATEs the row to `active=0`, then publishes a `PunishmentBroadcast` with action `revoked`. Other servers receive, clear the MuteCache entry if relevant. Future connects no longer find an active ban. + +## Failure modes + +| What | What happens | Mitigation | +|---|---|---| +| Redis is down on the issuing server | Local action happens, broadcast fails silently. Other servers don't know until the player reconnects (then DB query catches it). | Run multiple Redis instances, or accept the short window. | +| Receiver's MessageBus is down | Receiver misses the broadcast. Same fallback: next connect attempt queries the DB. | Same. | +| MySQL is down | Issue command itself fails with a database error. Nothing propagates. | Don't issue punishments during MySQL outages. | +| Auth token mismatch | The receiving server's bus rejects the broadcast as bad-auth. You'll see `messagebus_rejected_total` climb on NetworkCore. | Fix the token across the fleet. | +| Receiver has the same player online and the broadcast is lost | Player stays online (and unmuted/unbanned) until the issuing server's heartbeat re-syncs by RPC... which doesn't exist for punishments yet. | If this matters for you, add a periodic reconciliation: every N minutes, each server cross-checks its online players against the active-bans table. Not in v1. | + +The "broadcast lost" case is the only one where you'd see persistent inconsistency. In practice it's rare (Redis pub/sub doesn't lose messages between healthy connections) and self-corrects on the player's next reconnect. + +## What happens when a server crashes mid-broadcast + +- Issuer server crashes before publishing: the row is in MySQL, the broadcast never went. Other servers learn on next connect. +- Receiver server crashes mid-receive: the broadcast was published; this server happens to miss it. Same fallback applies for this server. + +There's no transactional guarantee across MySQL and Redis. That's a tradeoff we made deliberately - the simpler "DB is truth, bus is fast notification" model is robust enough. diff --git a/docs/06_Operations.md b/docs/06_Operations.md new file mode 100644 index 0000000..d01ebdd --- /dev/null +++ b/docs/06_Operations.md @@ -0,0 +1,77 @@ +# Operations + +## Smoke test after deploy + +1. `/networkcore status` - confirm "Database: ok" and "Network: connected". +2. Connect a staff alt. Run `/lookup `. You should see your own UUID and online status. +3. Connect a non-staff alt. Have a staff member run `/warn test`. The alt receives a chat message; nothing crashes. +4. `/sc test` from staff. Every other online staff member sees it. No non-staff sees it. +5. Look in MySQL: + ```sql + SELECT * FROM staff_punishments ORDER BY issued_at DESC LIMIT 5; + ``` + The warn should be the most recent row. + +## A staff member can't run staff commands + +1. Check what groups Hytale shows for them. There's no direct command for this in Staff; have them run `/lookup ` - if it works, they're staff. If it doesn't, the group is wrong. +2. `PermissionsModule.get().getGroupsForUser(uuid)` is the source. Confirm the group name they're assigned matches one in `permissions.staff_groups` in `config.json` (case-sensitive). +3. Group changes typically require the player to reconnect. + +## A ban isn't propagating + +1. On the issuing server: `/networkcore servers` - is the destination server in the list? If no, the destination isn't actually on the network. +2. On the issuing server: confirm `messagebus_published_total` is incrementing in `/metrics`. +3. On the destination server: confirm `messagebus_received_total` is incrementing. If it's rising but `messagebus_rejected_total` is also rising fast, you have an auth token mismatch. +4. Confirm the destination server's `network.auth_token` matches the issuer's. Different tokens cause silent message drops. +5. As a last resort: the destination server queries MySQL on connect via `BanEnforcer`. Have the target try to reconnect. If the ban still doesn't apply, the row may not actually be in the DB - check `SELECT * FROM staff_punishments WHERE target_uuid = ?`. + +## A muted player can still chat + +1. The mute might have expired. Check `expires_at` against now. +2. `MuteCache` on the chat server might be stale. The cache loads on connect; if the mute was issued *while the player was already online*, the broadcast path is what populates the cache. +3. If broadcasts aren't arriving, see the previous section. +4. As a fallback, kick + rejoin the player. `PlayerSeenTracker.onConnect` calls `loadMuteIntoCache` which forces a fresh DB read. + +## `players_seen.online` is stuck at 1 after a crash + +The plugin runs `markServerOffline(serverId)` in `start()`, which sets `online=0` for all rows whose `last_server_id` matches this server. So a normal restart fixes it. + +If you see online=1 for a player whose `last_server_id` belongs to a server that no longer exists (decommissioned, renamed): clear manually with `UPDATE staff_players_seen SET online=0 WHERE last_server_id = 'old-server-id'`. + +## Auditing + +Recent actions taken by a moderator: + +```sql +SELECT issued_at, type, target_name, reason +FROM staff_punishments +WHERE issued_by_name = 'StaffName' +ORDER BY issued_at DESC +LIMIT 50; +``` + +All actions in the last hour: + +```sql +SELECT * FROM staff_punishments +WHERE issued_at > (UNIX_TIMESTAMP() * 1000) - 3600000 +ORDER BY issued_at DESC; +``` + +Players who have been banned but later unbanned: + +```sql +SELECT target_name, COUNT(*) AS unbanned_bans +FROM staff_punishments +WHERE type IN ('BAN', 'TEMPBAN') AND revoked_at IS NOT NULL +GROUP BY target_uuid, target_name +ORDER BY unbanned_bans DESC; +``` + +## Things this plugin deliberately doesn't do + +- **IP bans.** Privacy implications and uncertainty about the Hytale auth model. Use the punishment system on UUIDs. +- **Appeals workflow.** Staff just unban. There's no in-game appeal form. Build that externally if you want it. +- **Multiple active bans on the same player.** The repository's "active ban" query returns the most recent one. Stacking is unsupported by design - it complicates logic without changing outcomes. +- **Per-world or per-server bans.** Punishments are network-wide. If you need per-world, that's a different feature (likely a separate plugin built on top of this). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..2ceefd7 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,8 @@ +# Staff Docs + +1. [01_Overview.md](01_Overview.md) - architecture and pieces +2. [02_Configuration.md](02_Configuration.md) - config.json + permission groups +3. [03_Commands.md](03_Commands.md) - every command, syntax, behavior +4. [04_Schema.md](04_Schema.md) - MySQL tables +5. [05_Cross_Server_Propagation.md](05_Cross_Server_Propagation.md) - how bans/mutes propagate +6. [06_Operations.md](06_Operations.md) - ops scenarios, debugging diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..b96f198 --- /dev/null +++ b/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + net.kewwbec + staff + 0.1.0 + jar + + Staff + KweebecNet staff tools: punishments, history, staff chat, player lookup + + + 21 + 21 + UTF-8 + ${user.home}/AppData/Roaming/Hytale/install/release/package/game/latest/Server/HytaleServer.jar + ${project.basedir}/../Core/target/NetworkCore-0.1.0.jar + + + + + com.hypixel.hytale + hytale-server + local + system + ${hytale.server.jar} + + + + net.kewwbec + networkcore + 0.1.0 + system + ${networkcore.jar} + + + + com.google.code.findbugs + jsr305 + 3.0.2 + provided + + + + + ${project.artifactId}-${project.version} + + diff --git a/src/main/java/net/kewwbec/staff/StaffPlugin.java b/src/main/java/net/kewwbec/staff/StaffPlugin.java new file mode 100644 index 0000000..d0a1bc1 --- /dev/null +++ b/src/main/java/net/kewwbec/staff/StaffPlugin.java @@ -0,0 +1,141 @@ +package net.kewwbec.staff; + +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.event.events.player.PlayerChatEvent; +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.event.events.player.PlayerSetupConnectEvent; +import com.hypixel.hytale.server.core.plugin.JavaPlugin; +import com.hypixel.hytale.server.core.plugin.JavaPluginInit; +import net.kewwbec.networkcore.NetworkCore; +import net.kewwbec.networkcore.api.DatabaseService; +import net.kewwbec.networkcore.api.MessageBus; +import net.kewwbec.staff.command.chat.StaffChatCommand; +import net.kewwbec.staff.command.lookup.HistoryCommand; +import net.kewwbec.staff.command.lookup.LookupCommand; +import net.kewwbec.staff.command.punish.PunishmentCommands; +import net.kewwbec.staff.config.StaffConfig; +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.staff.listener.BanEnforcer; +import net.kewwbec.staff.listener.ChatEnforcer; +import net.kewwbec.staff.listener.PlayerSeenTracker; +import net.kewwbec.staff.service.MuteCache; +import net.kewwbec.staff.service.PunishmentService; +import net.kewwbec.staff.service.StaffChatService; +import net.kewwbec.staff.util.PlayerResolver; +import net.kewwbec.staff.util.StaffPermissions; + +import javax.annotation.Nonnull; +import java.io.IOException; +import java.sql.SQLException; +import java.util.logging.Level; + +public final class StaffPlugin extends JavaPlugin { + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + private StaffConfig config; + private StaffPermissions permissions; + private MuteCache muteCache; + private PunishmentService punishmentService; + private StaffChatService staffChat; + + public StaffPlugin(@Nonnull JavaPluginInit init) { + super(init); + } + + @Override + protected void setup() { + try { + this.config = StaffConfigLoader.loadOrCreate(getDataDirectory()); + } catch (IOException e) { + ((HytaleLogger.Api) LOGGER.at(Level.SEVERE).withCause(e)).log("Failed to load Staff config"); + this.config = new StaffConfig(); + } + this.permissions = new StaffPermissions(config.permissions); + this.muteCache = new MuteCache(); + LOGGER.at(Level.INFO).log("Staff setup complete"); + } + + @Override + protected void start() { + NetworkCore core = NetworkCore.getInstance(); + DatabaseService db = core.getDatabase(); + if (db == null || !db.isHealthy()) { + ((HytaleLogger.Api) LOGGER.at(Level.SEVERE).withCause(new IllegalStateException("DatabaseService unavailable"))) + .log("Staff requires NetworkCore's MySQL DatabaseService to be enabled and healthy. Set mysql.enabled=true in NetworkCore's config."); + return; + } + MessageBus bus = core.getMessageBus(); + if (bus == null) { + LOGGER.at(Level.SEVERE).log("Staff requires NetworkCore's MessageBus to be available"); + return; + } + + try { + SchemaBootstrap.apply(db.getDataSource(), config.tables); + } catch (SQLException e) { + ((HytaleLogger.Api) LOGGER.at(Level.SEVERE).withCause(e)).log("Failed to bootstrap Staff schema"); + return; + } + + PunishmentRepository punishmentRepo = new PunishmentRepository(db.getDataSource(), config.tables.punishments); + PlayerSeenRepository seenRepo = new PlayerSeenRepository(db.getDataSource(), config.tables.players_seen); + + try { + int reset = seenRepo.markServerOffline(core.getServerId()); + if (reset > 0) { + LOGGER.at(Level.INFO).log("Reset %d stale online flags from previous run of this server", reset); + } + } catch (SQLException e) { + LOGGER.at(Level.WARNING).log("Failed to reset stale online flags: %s", e.getMessage()); + } + + PlayerResolver resolver = new PlayerResolver(seenRepo); + + this.staffChat = new StaffChatService(bus, config.chat, permissions, core.getServerId()); + staffChat.start(); + + this.punishmentService = new PunishmentService(punishmentRepo, muteCache, bus, core.getServerId()); + punishmentService.start(); + + BanEnforcer banEnforcer = new BanEnforcer(punishmentService); + ChatEnforcer chatEnforcer = new ChatEnforcer(muteCache); + PlayerSeenTracker seenTracker = new PlayerSeenTracker(seenRepo, muteCache, punishmentService, core.getServerId()); + + getEventRegistry().registerGlobal(PlayerSetupConnectEvent.class, banEnforcer::onSetupConnect); + getEventRegistry().registerGlobal(PlayerChatEvent.class, chatEnforcer::onChat); + getEventRegistry().registerGlobal(PlayerConnectEvent.class, seenTracker::onConnect); + getEventRegistry().registerGlobal(PlayerDisconnectEvent.class, seenTracker::onDisconnect); + + getCommandRegistry().registerCommand(new PunishmentCommands.Ban(punishmentService, staffChat, resolver, permissions)); + getCommandRegistry().registerCommand(new PunishmentCommands.TempBan(punishmentService, staffChat, resolver, permissions)); + getCommandRegistry().registerCommand(new PunishmentCommands.Mute(punishmentService, staffChat, resolver, permissions)); + getCommandRegistry().registerCommand(new PunishmentCommands.TempMute(punishmentService, staffChat, resolver, permissions)); + getCommandRegistry().registerCommand(new PunishmentCommands.Kick(punishmentService, staffChat, resolver, permissions)); + getCommandRegistry().registerCommand(new PunishmentCommands.Warn(punishmentService, staffChat, resolver, permissions)); + getCommandRegistry().registerCommand(new PunishmentCommands.Unban(punishmentService, staffChat, resolver, permissions)); + getCommandRegistry().registerCommand(new PunishmentCommands.Unmute(punishmentService, staffChat, resolver, permissions)); + getCommandRegistry().registerCommand(new StaffChatCommand(staffChat, permissions)); + getCommandRegistry().registerCommand(new LookupCommand(resolver, seenRepo, punishmentService, permissions)); + getCommandRegistry().registerCommand(new HistoryCommand(resolver, punishmentRepo, permissions)); + + LOGGER.at(Level.INFO).log("Staff started"); + } + + @Override + protected void shutdown() { + if (punishmentService != null) { + try { punishmentService.stop(); } catch (RuntimeException ignored) {} + punishmentService = null; + } + if (staffChat != null) { + try { staffChat.stop(); } catch (RuntimeException ignored) {} + staffChat = null; + } + LOGGER.at(Level.INFO).log("Staff shutdown complete"); + } +} diff --git a/src/main/java/net/kewwbec/staff/command/chat/StaffChatCommand.java b/src/main/java/net/kewwbec/staff/command/chat/StaffChatCommand.java new file mode 100644 index 0000000..0786966 --- /dev/null +++ b/src/main/java/net/kewwbec/staff/command/chat/StaffChatCommand.java @@ -0,0 +1,48 @@ +package net.kewwbec.staff.command.chat; + +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.RequiredArg; +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.service.StaffChatService; +import net.kewwbec.staff.util.StaffPermissions; + +import javax.annotation.Nonnull; +import java.awt.Color; + +public final class StaffChatCommand extends AbstractPlayerCommand { + + private final StaffChatService chat; + private final StaffPermissions perms; + private final RequiredArg messageArg = withRequiredArg("message", "Message to send", ArgTypes.STRING); + + public StaffChatCommand(@Nonnull StaffChatService chat, @Nonnull StaffPermissions perms) { + super("sc", "Send a message to staff chat (cross-server)"); + this.chat = chat; + this.perms = perms; + } + + @Override + protected void execute(@Nonnull CommandContext ctx, + @Nonnull Store store, + @Nonnull Ref ref, + @Nonnull PlayerRef sender, + @Nonnull World world) { + if (!perms.isStaff(sender)) { + ctx.sendMessage(Message.raw("You do not have permission to use staff chat.").color(Color.RED)); + return; + } + String message = messageArg.get(ctx); + if (message == null || message.isBlank()) { + ctx.sendMessage(Message.raw("Usage: /sc ").color(Color.YELLOW)); + return; + } + chat.send(sender, message); + } +} diff --git a/src/main/java/net/kewwbec/staff/command/lookup/HistoryCommand.java b/src/main/java/net/kewwbec/staff/command/lookup/HistoryCommand.java new file mode 100644 index 0000000..3bab15e --- /dev/null +++ b/src/main/java/net/kewwbec/staff/command/lookup/HistoryCommand.java @@ -0,0 +1,97 @@ +package net.kewwbec.staff.command.lookup; + +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.RequiredArg; +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.db.PunishmentRepository; +import net.kewwbec.staff.model.Punishment; +import net.kewwbec.staff.util.PlayerResolver; +import net.kewwbec.staff.util.StaffPermissions; + +import javax.annotation.Nonnull; +import java.awt.Color; +import java.sql.SQLException; +import java.time.Instant; +import java.util.List; + +public final class HistoryCommand extends AbstractPlayerCommand { + + private static final int LIMIT = 20; + + private final PlayerResolver resolver; + private final PunishmentRepository repo; + private final StaffPermissions perms; + private final RequiredArg targetArg = withRequiredArg("player", "Player name", ArgTypes.STRING); + + public HistoryCommand(@Nonnull PlayerResolver resolver, + @Nonnull PunishmentRepository repo, + @Nonnull StaffPermissions perms) { + super("history", "Show a player's recent punishment history"); + this.resolver = resolver; + this.repo = repo; + this.perms = perms; + } + + @Override + protected void execute(@Nonnull CommandContext ctx, + @Nonnull Store store, + @Nonnull Ref ref, + @Nonnull PlayerRef issuer, + @Nonnull World world) { + if (!perms.isStaff(issuer)) { + ctx.sendMessage(Message.raw("You do not have permission to use this command.").color(Color.RED)); + return; + } + String name = targetArg.get(ctx); + if (name == null || name.isBlank()) { + ctx.sendMessage(Message.raw("Usage: /history ").color(Color.YELLOW)); + return; + } + + PlayerResolver.Resolved resolved; + try { + resolved = resolver.byName(name); + } catch (SQLException e) { + ctx.sendMessage(Message.raw("Database error: " + e.getMessage()).color(Color.RED)); + return; + } + if (resolved == null) { + ctx.sendMessage(Message.raw("No player found with that name.").color(Color.RED)); + return; + } + + try { + List history = repo.findHistory(resolved.uuid(), LIMIT); + ctx.sendMessage(Message.raw("=== Punishment history: " + resolved.name() + " ===").color(Color.YELLOW)); + if (history.isEmpty()) { + ctx.sendMessage(Message.raw("No punishments on record.").color(Color.GREEN)); + return; + } + for (Punishment p : history) { + String state; + if (!p.active() || p.revokedAt() != null) { + state = "REVOKED"; + } else if (p.expiresAt() != null && p.expiresAt() <= System.currentTimeMillis()) { + state = "EXPIRED"; + } else { + state = "ACTIVE"; + } + ctx.sendMessage(Message.raw( + "#" + p.id() + " " + state + " " + p.type() + " by " + p.issuedByName() + + " @ " + Instant.ofEpochMilli(p.issuedAt()) + + " | " + p.reason() + ).color(state.equals("ACTIVE") ? Color.RED : Color.WHITE)); + } + ctx.sendMessage(Message.raw("(showing last " + history.size() + ", max " + LIMIT + ")").color(Color.GRAY)); + } catch (SQLException e) { + ctx.sendMessage(Message.raw("Database error: " + e.getMessage()).color(Color.RED)); + } + } +} diff --git a/src/main/java/net/kewwbec/staff/command/lookup/LookupCommand.java b/src/main/java/net/kewwbec/staff/command/lookup/LookupCommand.java new file mode 100644 index 0000000..3a9d019 --- /dev/null +++ b/src/main/java/net/kewwbec/staff/command/lookup/LookupCommand.java @@ -0,0 +1,114 @@ +package net.kewwbec.staff.command.lookup; + +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.RequiredArg; +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.db.PlayerSeenRepository; +import net.kewwbec.staff.model.PlayerSeen; +import net.kewwbec.staff.model.Punishment; +import net.kewwbec.staff.service.PunishmentService; +import net.kewwbec.staff.util.DurationParser; +import net.kewwbec.staff.util.PlayerResolver; +import net.kewwbec.staff.util.StaffPermissions; + +import javax.annotation.Nonnull; +import java.awt.Color; +import java.sql.SQLException; +import java.time.Instant; +import java.util.Optional; + +public final class LookupCommand extends AbstractPlayerCommand { + + private final PlayerResolver resolver; + private final PlayerSeenRepository seen; + private final PunishmentService punishments; + private final StaffPermissions perms; + private final RequiredArg targetArg = withRequiredArg("player", "Player name or UUID", ArgTypes.STRING); + + public LookupCommand(@Nonnull PlayerResolver resolver, + @Nonnull PlayerSeenRepository seen, + @Nonnull PunishmentService punishments, + @Nonnull StaffPermissions perms) { + super("lookup", "Look up a player"); + this.resolver = resolver; + this.seen = seen; + this.punishments = punishments; + this.perms = perms; + } + + @Override + protected void execute(@Nonnull CommandContext ctx, + @Nonnull Store store, + @Nonnull Ref ref, + @Nonnull PlayerRef issuer, + @Nonnull World world) { + if (!perms.isStaff(issuer)) { + ctx.sendMessage(Message.raw("You do not have permission to use this command.").color(Color.RED)); + return; + } + String name = targetArg.get(ctx); + if (name == null || name.isBlank()) { + ctx.sendMessage(Message.raw("Usage: /lookup ").color(Color.YELLOW)); + return; + } + + PlayerResolver.Resolved resolved; + try { + resolved = resolver.byName(name); + } catch (SQLException e) { + ctx.sendMessage(Message.raw("Database error: " + e.getMessage()).color(Color.RED)); + return; + } + if (resolved == null) { + ctx.sendMessage(Message.raw("No player found with that name.").color(Color.RED)); + return; + } + + ctx.sendMessage(Message.raw("=== " + resolved.name() + " ===").color(Color.YELLOW)); + ctx.sendMessage(Message.raw("UUID: " + resolved.uuid()).color(Color.WHITE)); + + try { + Optional seenRow = seen.findByUuid(resolved.uuid()); + if (seenRow.isPresent()) { + PlayerSeen s = seenRow.get(); + ctx.sendMessage(Message.raw( + "Status: " + (s.online() ? "ONLINE on " + (s.lastServerId() == null ? "?" : s.lastServerId()) : "offline") + ).color(s.online() ? Color.GREEN : Color.GRAY)); + ctx.sendMessage(Message.raw("Last seen: " + Instant.ofEpochMilli(s.lastSeen())).color(Color.WHITE)); + ctx.sendMessage(Message.raw("First seen: " + Instant.ofEpochMilli(s.firstSeen())).color(Color.WHITE)); + if (s.lastServerId() != null) { + ctx.sendMessage(Message.raw("Last server: " + s.lastServerId()).color(Color.WHITE)); + } + } else { + ctx.sendMessage(Message.raw("No presence record (player has never connected, or records were wiped).").color(Color.GRAY)); + } + + Optional ban = punishments.getActiveBan(resolved.uuid()); + if (ban.isPresent()) { + Punishment b = ban.get(); + String exp = (b.expiresAt() == null) ? "permanent" + : "expires in " + DurationParser.formatRemaining(b.expiresAt() - System.currentTimeMillis()); + ctx.sendMessage(Message.raw("Active ban: " + exp + " | " + b.reason() + " (by " + b.issuedByName() + ")").color(Color.RED)); + } + Optional mute = punishments.getActiveMute(resolved.uuid()); + if (mute.isPresent()) { + Punishment m = mute.get(); + String exp = (m.expiresAt() == null) ? "permanent" + : "expires in " + DurationParser.formatRemaining(m.expiresAt() - System.currentTimeMillis()); + ctx.sendMessage(Message.raw("Active mute: " + exp + " | " + m.reason() + " (by " + m.issuedByName() + ")").color(Color.RED)); + } + if (ban.isEmpty() && mute.isEmpty()) { + ctx.sendMessage(Message.raw("No active punishments.").color(Color.GREEN)); + } + } catch (SQLException e) { + ctx.sendMessage(Message.raw("Database error: " + e.getMessage()).color(Color.RED)); + } + } +} diff --git a/src/main/java/net/kewwbec/staff/command/punish/PunishmentCommands.java b/src/main/java/net/kewwbec/staff/command/punish/PunishmentCommands.java new file mode 100644 index 0000000..155c94e --- /dev/null +++ b/src/main/java/net/kewwbec/staff/command/punish/PunishmentCommands.java @@ -0,0 +1,328 @@ +package net.kewwbec.staff.command.punish; + +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.RequiredArg; +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.model.Punishment; +import net.kewwbec.staff.model.PunishmentType; +import net.kewwbec.staff.service.PunishmentService; +import net.kewwbec.staff.service.StaffChatService; +import net.kewwbec.staff.util.DurationParser; +import net.kewwbec.staff.util.PlayerResolver; +import net.kewwbec.staff.util.StaffPermissions; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.awt.Color; +import java.sql.SQLException; + +public final class PunishmentCommands { + + private PunishmentCommands() { + } + + static boolean requireStaff(@Nonnull CommandContext ctx, @Nonnull PlayerRef ref, @Nonnull StaffPermissions perms) { + if (perms.isStaff(ref)) return true; + ctx.sendMessage(Message.raw("You do not have permission to use this command.").color(Color.RED)); + return false; + } + + @Nullable + static PlayerResolver.Resolved resolve(@Nonnull CommandContext ctx, + @Nonnull PlayerResolver resolver, + @Nonnull String name) { + try { + PlayerResolver.Resolved resolved = resolver.byName(name); + if (resolved == null) { + ctx.sendMessage(Message.raw("No player found with that name (online or in history).").color(Color.RED)); + } + return resolved; + } catch (SQLException e) { + ctx.sendMessage(Message.raw("Database error: " + e.getMessage()).color(Color.RED)); + return null; + } + } + + static void issue(@Nonnull CommandContext ctx, + @Nonnull PunishmentService punishments, + @Nonnull StaffChatService staffChat, + @Nonnull PlayerResolver.Resolved target, + @Nonnull PunishmentType type, + @Nonnull String reason, + @Nonnull PlayerRef issuer, + @Nullable Long durationMs) { + try { + Punishment p = punishments.issue(target.uuid(), target.name(), type, reason, issuer.getUuid(), issuer.getUsername(), durationMs); + String durationStr = (durationMs == null) ? "permanent" : DurationParser.formatRemaining(durationMs); + ctx.sendMessage(Message.raw( + type.name() + " applied to " + target.name() + " (" + durationStr + "): " + reason + ).color(Color.GREEN)); + staffChat.announce("[" + type.name() + "] " + issuer.getUsername() + " -> " + target.name() + " (" + durationStr + ") " + reason); + } catch (SQLException e) { + ctx.sendMessage(Message.raw("Database error: " + e.getMessage()).color(Color.RED)); + } + } + + public static final class Ban extends AbstractPlayerCommand { + private final PunishmentService punishments; + private final StaffChatService staffChat; + private final PlayerResolver resolver; + private final StaffPermissions perms; + private final RequiredArg targetArg = withRequiredArg("player", "Target player", ArgTypes.STRING); + private final RequiredArg reasonArg = withRequiredArg("reason", "Reason", ArgTypes.STRING); + + public Ban(@Nonnull PunishmentService p, @Nonnull StaffChatService c, @Nonnull PlayerResolver r, @Nonnull StaffPermissions perms) { + super("ban", "Permanently ban a player"); + this.punishments = p; this.staffChat = c; this.resolver = r; this.perms = perms; + } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, @Nonnull Ref ref, @Nonnull PlayerRef issuer, @Nonnull World world) { + if (!requireStaff(ctx, issuer, perms)) return; + String name = targetArg.get(ctx); + String reason = reasonArg.get(ctx); + if (name == null || reason == null) { + ctx.sendMessage(Message.raw("Usage: /ban ").color(Color.YELLOW)); + return; + } + PlayerResolver.Resolved t = resolve(ctx, resolver, name); + if (t == null) return; + issue(ctx, punishments, staffChat, t, PunishmentType.BAN, reason, issuer, null); + } + } + + public static final class TempBan extends AbstractPlayerCommand { + private final PunishmentService punishments; + private final StaffChatService staffChat; + private final PlayerResolver resolver; + private final StaffPermissions perms; + private final RequiredArg targetArg = withRequiredArg("player", "Target player", ArgTypes.STRING); + private final RequiredArg durationArg = withRequiredArg("duration", "e.g. 30m, 2h, 7d", ArgTypes.STRING); + private final RequiredArg reasonArg = withRequiredArg("reason", "Reason", ArgTypes.STRING); + + public TempBan(@Nonnull PunishmentService p, @Nonnull StaffChatService c, @Nonnull PlayerResolver r, @Nonnull StaffPermissions perms) { + super("tempban", "Temporarily ban a player"); + this.punishments = p; this.staffChat = c; this.resolver = r; this.perms = perms; + } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, @Nonnull Ref ref, @Nonnull PlayerRef issuer, @Nonnull World world) { + if (!requireStaff(ctx, issuer, perms)) return; + String name = targetArg.get(ctx); + String durationStr = durationArg.get(ctx); + String reason = reasonArg.get(ctx); + if (name == null || durationStr == null || reason == null) { + ctx.sendMessage(Message.raw("Usage: /tempban ").color(Color.YELLOW)); + return; + } + Long durationMs = DurationParser.parseMillis(durationStr); + if (durationMs == null) { + ctx.sendMessage(Message.raw("Bad duration. Use 30s, 5m, 2h, 7d, 1w.").color(Color.RED)); + return; + } + PlayerResolver.Resolved t = resolve(ctx, resolver, name); + if (t == null) return; + issue(ctx, punishments, staffChat, t, PunishmentType.TEMPBAN, reason, issuer, durationMs); + } + } + + public static final class Mute extends AbstractPlayerCommand { + private final PunishmentService punishments; + private final StaffChatService staffChat; + private final PlayerResolver resolver; + private final StaffPermissions perms; + private final RequiredArg targetArg = withRequiredArg("player", "Target player", ArgTypes.STRING); + private final RequiredArg reasonArg = withRequiredArg("reason", "Reason", ArgTypes.STRING); + + public Mute(@Nonnull PunishmentService p, @Nonnull StaffChatService c, @Nonnull PlayerResolver r, @Nonnull StaffPermissions perms) { + super("mute", "Permanently mute a player"); + this.punishments = p; this.staffChat = c; this.resolver = r; this.perms = perms; + } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, @Nonnull Ref ref, @Nonnull PlayerRef issuer, @Nonnull World world) { + if (!requireStaff(ctx, issuer, perms)) return; + String name = targetArg.get(ctx); + String reason = reasonArg.get(ctx); + if (name == null || reason == null) { + ctx.sendMessage(Message.raw("Usage: /mute ").color(Color.YELLOW)); + return; + } + PlayerResolver.Resolved t = resolve(ctx, resolver, name); + if (t == null) return; + issue(ctx, punishments, staffChat, t, PunishmentType.MUTE, reason, issuer, null); + } + } + + public static final class TempMute extends AbstractPlayerCommand { + private final PunishmentService punishments; + private final StaffChatService staffChat; + private final PlayerResolver resolver; + private final StaffPermissions perms; + private final RequiredArg targetArg = withRequiredArg("player", "Target player", ArgTypes.STRING); + private final RequiredArg durationArg = withRequiredArg("duration", "e.g. 30m, 2h", ArgTypes.STRING); + private final RequiredArg reasonArg = withRequiredArg("reason", "Reason", ArgTypes.STRING); + + public TempMute(@Nonnull PunishmentService p, @Nonnull StaffChatService c, @Nonnull PlayerResolver r, @Nonnull StaffPermissions perms) { + super("tempmute", "Temporarily mute a player"); + this.punishments = p; this.staffChat = c; this.resolver = r; this.perms = perms; + } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, @Nonnull Ref ref, @Nonnull PlayerRef issuer, @Nonnull World world) { + if (!requireStaff(ctx, issuer, perms)) return; + String name = targetArg.get(ctx); + String durationStr = durationArg.get(ctx); + String reason = reasonArg.get(ctx); + if (name == null || durationStr == null || reason == null) { + ctx.sendMessage(Message.raw("Usage: /tempmute ").color(Color.YELLOW)); + return; + } + Long durationMs = DurationParser.parseMillis(durationStr); + if (durationMs == null) { + ctx.sendMessage(Message.raw("Bad duration. Use 30s, 5m, 2h, 7d.").color(Color.RED)); + return; + } + PlayerResolver.Resolved t = resolve(ctx, resolver, name); + if (t == null) return; + issue(ctx, punishments, staffChat, t, PunishmentType.TEMPMUTE, reason, issuer, durationMs); + } + } + + public static final class Kick extends AbstractPlayerCommand { + private final PunishmentService punishments; + private final StaffChatService staffChat; + private final PlayerResolver resolver; + private final StaffPermissions perms; + private final RequiredArg targetArg = withRequiredArg("player", "Target player", ArgTypes.STRING); + private final RequiredArg reasonArg = withRequiredArg("reason", "Reason", ArgTypes.STRING); + + public Kick(@Nonnull PunishmentService p, @Nonnull StaffChatService c, @Nonnull PlayerResolver r, @Nonnull StaffPermissions perms) { + super("kick", "Kick a player from the network"); + this.punishments = p; this.staffChat = c; this.resolver = r; this.perms = perms; + } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, @Nonnull Ref ref, @Nonnull PlayerRef issuer, @Nonnull World world) { + if (!requireStaff(ctx, issuer, perms)) return; + String name = targetArg.get(ctx); + String reason = reasonArg.get(ctx); + if (name == null || reason == null) { + ctx.sendMessage(Message.raw("Usage: /kick ").color(Color.YELLOW)); + return; + } + PlayerResolver.Resolved t = resolve(ctx, resolver, name); + if (t == null) return; + issue(ctx, punishments, staffChat, t, PunishmentType.KICK, reason, issuer, null); + } + } + + public static final class Warn extends AbstractPlayerCommand { + private final PunishmentService punishments; + private final StaffChatService staffChat; + private final PlayerResolver resolver; + private final StaffPermissions perms; + private final RequiredArg targetArg = withRequiredArg("player", "Target player", ArgTypes.STRING); + private final RequiredArg reasonArg = withRequiredArg("reason", "Reason", ArgTypes.STRING); + + public Warn(@Nonnull PunishmentService p, @Nonnull StaffChatService c, @Nonnull PlayerResolver r, @Nonnull StaffPermissions perms) { + super("warn", "Warn a player"); + this.punishments = p; this.staffChat = c; this.resolver = r; this.perms = perms; + } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, @Nonnull Ref ref, @Nonnull PlayerRef issuer, @Nonnull World world) { + if (!requireStaff(ctx, issuer, perms)) return; + String name = targetArg.get(ctx); + String reason = reasonArg.get(ctx); + if (name == null || reason == null) { + ctx.sendMessage(Message.raw("Usage: /warn ").color(Color.YELLOW)); + return; + } + PlayerResolver.Resolved t = resolve(ctx, resolver, name); + if (t == null) return; + issue(ctx, punishments, staffChat, t, PunishmentType.WARN, reason, issuer, null); + } + } + + public static final class Unban extends AbstractPlayerCommand { + private final PunishmentService punishments; + private final StaffChatService staffChat; + private final PlayerResolver resolver; + private final StaffPermissions perms; + private final RequiredArg targetArg = withRequiredArg("player", "Target player", ArgTypes.STRING); + + public Unban(@Nonnull PunishmentService p, @Nonnull StaffChatService c, @Nonnull PlayerResolver r, @Nonnull StaffPermissions perms) { + super("unban", "Lift a player's ban"); + this.punishments = p; this.staffChat = c; this.resolver = r; this.perms = perms; + } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, @Nonnull Ref ref, @Nonnull PlayerRef issuer, @Nonnull World world) { + if (!requireStaff(ctx, issuer, perms)) return; + String name = targetArg.get(ctx); + if (name == null) { + ctx.sendMessage(Message.raw("Usage: /unban ").color(Color.YELLOW)); + return; + } + PlayerResolver.Resolved t = resolve(ctx, resolver, name); + if (t == null) return; + try { + int n = punishments.revoke(t.uuid(), PunishmentType.BAN, issuer.getUuid(), issuer.getUsername(), null); + n += punishments.revoke(t.uuid(), PunishmentType.TEMPBAN, issuer.getUuid(), issuer.getUsername(), null); + if (n == 0) { + ctx.sendMessage(Message.raw(t.name() + " has no active ban.").color(Color.YELLOW)); + } else { + ctx.sendMessage(Message.raw("Unbanned " + t.name() + ".").color(Color.GREEN)); + staffChat.announce("[UNBAN] " + issuer.getUsername() + " unbanned " + t.name()); + } + } catch (SQLException e) { + ctx.sendMessage(Message.raw("Database error: " + e.getMessage()).color(Color.RED)); + } + } + } + + public static final class Unmute extends AbstractPlayerCommand { + private final PunishmentService punishments; + private final StaffChatService staffChat; + private final PlayerResolver resolver; + private final StaffPermissions perms; + private final RequiredArg targetArg = withRequiredArg("player", "Target player", ArgTypes.STRING); + + public Unmute(@Nonnull PunishmentService p, @Nonnull StaffChatService c, @Nonnull PlayerResolver r, @Nonnull StaffPermissions perms) { + super("unmute", "Lift a player's mute"); + this.punishments = p; this.staffChat = c; this.resolver = r; this.perms = perms; + } + + @Override + protected void execute(@Nonnull CommandContext ctx, @Nonnull Store store, @Nonnull Ref ref, @Nonnull PlayerRef issuer, @Nonnull World world) { + if (!requireStaff(ctx, issuer, perms)) return; + String name = targetArg.get(ctx); + if (name == null) { + ctx.sendMessage(Message.raw("Usage: /unmute ").color(Color.YELLOW)); + return; + } + PlayerResolver.Resolved t = resolve(ctx, resolver, name); + if (t == null) return; + try { + int n = punishments.revoke(t.uuid(), PunishmentType.MUTE, issuer.getUuid(), issuer.getUsername(), null); + n += punishments.revoke(t.uuid(), PunishmentType.TEMPMUTE, issuer.getUuid(), issuer.getUsername(), null); + if (n == 0) { + ctx.sendMessage(Message.raw(t.name() + " has no active mute.").color(Color.YELLOW)); + } else { + ctx.sendMessage(Message.raw("Unmuted " + t.name() + ".").color(Color.GREEN)); + staffChat.announce("[UNMUTE] " + issuer.getUsername() + " unmuted " + t.name()); + } + } catch (SQLException e) { + ctx.sendMessage(Message.raw("Database error: " + e.getMessage()).color(Color.RED)); + } + } + } +} diff --git a/src/main/java/net/kewwbec/staff/config/StaffConfig.java b/src/main/java/net/kewwbec/staff/config/StaffConfig.java new file mode 100644 index 0000000..a3fc4a2 --- /dev/null +++ b/src/main/java/net/kewwbec/staff/config/StaffConfig.java @@ -0,0 +1,26 @@ +package net.kewwbec.staff.config; + +import java.util.ArrayList; +import java.util.List; + +public final class StaffConfig { + + public PermissionsSection permissions = new PermissionsSection(); + public ChatSection chat = new ChatSection(); + public TableSection tables = new TableSection(); + + public static final class PermissionsSection { + public List staff_groups = new ArrayList<>(List.of("OP", "ADMIN", "MOD", "HELPER")); + public List admin_groups = new ArrayList<>(List.of("OP", "ADMIN")); + } + + public static final class ChatSection { + public String channel = "staff.chat"; + public String prefix = "[Staff]"; + } + + public static final class TableSection { + public String punishments = "staff_punishments"; + public String players_seen = "staff_players_seen"; + } +} diff --git a/src/main/java/net/kewwbec/staff/config/StaffConfigLoader.java b/src/main/java/net/kewwbec/staff/config/StaffConfigLoader.java new file mode 100644 index 0000000..d829468 --- /dev/null +++ b/src/main/java/net/kewwbec/staff/config/StaffConfigLoader.java @@ -0,0 +1,36 @@ +package net.kewwbec.staff.config; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import javax.annotation.Nonnull; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +public final class StaffConfigLoader { + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private StaffConfigLoader() { + } + + @Nonnull + public static StaffConfig loadOrCreate(@Nonnull Path dataDirectory) throws IOException { + Path file = dataDirectory.resolve("config.json"); + if (!Files.exists(file)) { + Files.createDirectories(dataDirectory); + StaffConfig fresh = new StaffConfig(); + Files.writeString(file, GSON.toJson(fresh), StandardCharsets.UTF_8); + return fresh; + } + String json = Files.readString(file, StandardCharsets.UTF_8); + StaffConfig parsed = GSON.fromJson(json, StaffConfig.class); + if (parsed == null) parsed = new StaffConfig(); + if (parsed.permissions == null) parsed.permissions = new StaffConfig.PermissionsSection(); + if (parsed.chat == null) parsed.chat = new StaffConfig.ChatSection(); + if (parsed.tables == null) parsed.tables = new StaffConfig.TableSection(); + return parsed; + } +} diff --git a/src/main/java/net/kewwbec/staff/db/PlayerSeenRepository.java b/src/main/java/net/kewwbec/staff/db/PlayerSeenRepository.java new file mode 100644 index 0000000..fbf5a5a --- /dev/null +++ b/src/main/java/net/kewwbec/staff/db/PlayerSeenRepository.java @@ -0,0 +1,95 @@ +package net.kewwbec.staff.db; + +import net.kewwbec.staff.model.PlayerSeen; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; +import java.util.Optional; +import java.util.UUID; + +public final class PlayerSeenRepository { + + private final DataSource ds; + private final String table; + + public PlayerSeenRepository(@Nonnull DataSource ds, @Nonnull String table) { + this.ds = ds; + this.table = table; + } + + public void recordJoin(@Nonnull UUID uuid, @Nonnull String name, @Nonnull String serverId, long now) throws SQLException { + String sql = "INSERT INTO " + table + " (uuid, last_name, first_seen, last_seen, last_server_id, online) " + + "VALUES (?, ?, ?, ?, ?, 1) " + + "ON DUPLICATE KEY UPDATE last_name = VALUES(last_name), last_seen = VALUES(last_seen), last_server_id = VALUES(last_server_id), online = 1"; + try (Connection c = ds.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, uuid.toString()); + ps.setString(2, name); + ps.setLong(3, now); + ps.setLong(4, now); + ps.setString(5, serverId); + ps.executeUpdate(); + } + } + + public void recordLeave(@Nonnull UUID uuid, long now) throws SQLException { + String sql = "UPDATE " + table + " SET last_seen = ?, online = 0 WHERE uuid = ?"; + try (Connection c = ds.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setLong(1, now); + ps.setString(2, uuid.toString()); + ps.executeUpdate(); + } + } + + /** + * Mark all players that were online on this server as offline. Run on plugin start to recover from crashes. + */ + public int markServerOffline(@Nonnull String serverId) throws SQLException { + String sql = "UPDATE " + table + " SET online = 0 WHERE last_server_id = ? AND online = 1"; + try (Connection c = ds.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, serverId); + return ps.executeUpdate(); + } + } + + @Nonnull + public Optional findByUuid(@Nonnull UUID uuid) throws SQLException { + String sql = "SELECT * FROM " + table + " WHERE uuid = ?"; + try (Connection c = ds.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, uuid.toString()); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) return Optional.of(map(rs)); + } + } + return Optional.empty(); + } + + @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"; + try (Connection c = ds.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, name); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) return Optional.of(map(rs)); + } + } + return Optional.empty(); + } + + @Nonnull + private PlayerSeen map(@Nonnull ResultSet rs) throws SQLException { + return new PlayerSeen( + UUID.fromString(rs.getString("uuid")), + rs.getString("last_name"), + rs.getLong("first_seen"), + rs.getLong("last_seen"), + rs.getString("last_server_id"), + rs.getBoolean("online") + ); + } +} diff --git a/src/main/java/net/kewwbec/staff/db/PunishmentRepository.java b/src/main/java/net/kewwbec/staff/db/PunishmentRepository.java new file mode 100644 index 0000000..ecb39a5 --- /dev/null +++ b/src/main/java/net/kewwbec/staff/db/PunishmentRepository.java @@ -0,0 +1,149 @@ +package net.kewwbec.staff.db; + +import net.kewwbec.staff.model.Punishment; +import net.kewwbec.staff.model.PunishmentType; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Types; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public final class PunishmentRepository { + + private final DataSource ds; + private final String table; + + public PunishmentRepository(@Nonnull DataSource ds, @Nonnull String table) { + this.ds = ds; + this.table = table; + } + + public long insert(@Nonnull Punishment p) throws SQLException { + String sql = "INSERT INTO " + table + " (target_uuid, target_name, type, reason, issued_by_uuid, issued_by_name, issued_at, expires_at, issued_on_server, active) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + try (Connection c = ds.getConnection(); + PreparedStatement ps = c.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { + ps.setString(1, p.targetUuid().toString()); + ps.setString(2, p.targetName()); + ps.setString(3, p.type().name()); + ps.setString(4, p.reason()); + ps.setString(5, p.issuedByUuid().toString()); + ps.setString(6, p.issuedByName()); + ps.setLong(7, p.issuedAt()); + if (p.expiresAt() != null) ps.setLong(8, p.expiresAt()); else ps.setNull(8, Types.BIGINT); + ps.setString(9, p.issuedOnServer()); + ps.setBoolean(10, p.active()); + ps.executeUpdate(); + try (ResultSet keys = ps.getGeneratedKeys()) { + if (keys.next()) return keys.getLong(1); + } + return -1L; + } + } + + @Nonnull + public Optional findActiveByTargetAndType(@Nonnull UUID target, @Nonnull PunishmentType type, long now) throws SQLException { + String sql = "SELECT * FROM " + table + + " WHERE target_uuid = ? AND type = ? AND active = 1 AND (expires_at IS NULL OR expires_at > ?) AND revoked_at IS NULL " + + "ORDER BY issued_at DESC LIMIT 1"; + try (Connection c = ds.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, target.toString()); + ps.setString(2, type.name()); + ps.setLong(3, now); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) return Optional.of(map(rs)); + } + } + return Optional.empty(); + } + + @Nonnull + public Optional findActiveBan(@Nonnull UUID target, long now) throws SQLException { + Optional ban = findActiveByTargetAndType(target, PunishmentType.BAN, now); + if (ban.isPresent()) return ban; + return findActiveByTargetAndType(target, PunishmentType.TEMPBAN, now); + } + + @Nonnull + public Optional findActiveMute(@Nonnull UUID target, long now) throws SQLException { + Optional mute = findActiveByTargetAndType(target, PunishmentType.MUTE, now); + if (mute.isPresent()) return mute; + return findActiveByTargetAndType(target, PunishmentType.TEMPMUTE, now); + } + + @Nonnull + public List findHistory(@Nonnull UUID target, int limit) throws SQLException { + String sql = "SELECT * FROM " + table + " WHERE target_uuid = ? ORDER BY issued_at DESC LIMIT ?"; + List out = new ArrayList<>(); + try (Connection c = ds.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, target.toString()); + ps.setInt(2, 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)) { + ps.setLong(1, revokedAt); + ps.setString(2, revokedBy.toString()); + ps.setString(3, revokedByName); + if (reason != null) ps.setString(4, reason); else ps.setNull(4, Types.VARCHAR); + ps.setLong(5, id); + return ps.executeUpdate() > 0; + } + } + + public int revokeActiveOfType(@Nonnull UUID target, @Nonnull PunishmentType type, @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 target_uuid = ? AND type = ? AND active = 1 AND revoked_at IS NULL"; + try (Connection c = ds.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setLong(1, revokedAt); + ps.setString(2, revokedBy.toString()); + ps.setString(3, revokedByName); + if (reason != null) ps.setString(4, reason); else ps.setNull(4, Types.VARCHAR); + ps.setString(5, target.toString()); + ps.setString(6, type.name()); + return ps.executeUpdate(); + } + } + + @Nonnull + private Punishment map(@Nonnull ResultSet rs) throws SQLException { + long expiresRaw = rs.getLong("expires_at"); + Long expires = rs.wasNull() ? null : expiresRaw; + long revokedRaw = rs.getLong("revoked_at"); + Long revoked = rs.wasNull() ? null : revokedRaw; + String revokedByRaw = rs.getString("revoked_by_uuid"); + UUID revokedBy = revokedByRaw == null ? null : UUID.fromString(revokedByRaw); + return new Punishment( + rs.getLong("id"), + UUID.fromString(rs.getString("target_uuid")), + rs.getString("target_name"), + PunishmentType.valueOf(rs.getString("type")), + rs.getString("reason"), + UUID.fromString(rs.getString("issued_by_uuid")), + rs.getString("issued_by_name"), + rs.getLong("issued_at"), + expires, + rs.getString("issued_on_server"), + rs.getBoolean("active"), + revoked, + revokedBy, + rs.getString("revoked_by_name"), + rs.getString("revoke_reason") + ); + } +} diff --git a/src/main/java/net/kewwbec/staff/db/SchemaBootstrap.java b/src/main/java/net/kewwbec/staff/db/SchemaBootstrap.java new file mode 100644 index 0000000..c330944 --- /dev/null +++ b/src/main/java/net/kewwbec/staff/db/SchemaBootstrap.java @@ -0,0 +1,64 @@ +package net.kewwbec.staff.db; + +import com.hypixel.hytale.logger.HytaleLogger; +import net.kewwbec.staff.config.StaffConfig; + +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 SchemaBootstrap { + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + private SchemaBootstrap() { + } + + public static void apply(@Nonnull DataSource ds, @Nonnull StaffConfig.TableSection tables) throws SQLException { + try (Connection c = ds.getConnection(); Statement s = c.createStatement()) { + s.executeUpdate(""" + CREATE TABLE IF NOT EXISTS %s ( + id BIGINT NOT NULL AUTO_INCREMENT, + target_uuid CHAR(36) NOT NULL, + target_name VARCHAR(64) NOT NULL, + type VARCHAR(16) NOT NULL, + reason VARCHAR(512) NOT NULL, + issued_by_uuid CHAR(36) NOT NULL, + issued_by_name VARCHAR(64) NOT NULL, + issued_at BIGINT NOT NULL, + expires_at BIGINT NULL, + issued_on_server VARCHAR(64) NOT NULL, + active TINYINT(1) NOT NULL DEFAULT 1, + revoked_at BIGINT NULL, + revoked_by_uuid CHAR(36) NULL, + revoked_by_name VARCHAR(64) NULL, + revoke_reason VARCHAR(512) NULL, + PRIMARY KEY (id), + KEY idx_target_uuid (target_uuid), + KEY idx_target_active (target_uuid, type, active), + KEY idx_issued_at (issued_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """.formatted(tables.punishments)); + + s.executeUpdate(""" + CREATE TABLE IF NOT EXISTS %s ( + uuid CHAR(36) NOT NULL, + last_name VARCHAR(64) NOT NULL, + first_seen BIGINT NOT NULL, + last_seen BIGINT NOT NULL, + last_server_id VARCHAR(64) NULL, + online TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (uuid), + KEY idx_last_name (last_name), + KEY idx_last_seen (last_seen) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """.formatted(tables.players_seen)); + + LOGGER.at(Level.INFO).log("Staff schema bootstrap complete (tables: %s, %s)", + tables.punishments, tables.players_seen); + } + } +} diff --git a/src/main/java/net/kewwbec/staff/listener/BanEnforcer.java b/src/main/java/net/kewwbec/staff/listener/BanEnforcer.java new file mode 100644 index 0000000..20b4b7a --- /dev/null +++ b/src/main/java/net/kewwbec/staff/listener/BanEnforcer.java @@ -0,0 +1,35 @@ +package net.kewwbec.staff.listener; + +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.event.events.player.PlayerSetupConnectEvent; +import net.kewwbec.staff.model.Punishment; +import net.kewwbec.staff.service.PunishmentService; + +import javax.annotation.Nonnull; +import java.sql.SQLException; +import java.util.Optional; +import java.util.logging.Level; + +public final class BanEnforcer { + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + private final PunishmentService punishments; + + public BanEnforcer(@Nonnull PunishmentService punishments) { + this.punishments = punishments; + } + + public void onSetupConnect(@Nonnull PlayerSetupConnectEvent event) { + try { + Optional ban = punishments.getActiveBan(event.getUuid()); + if (ban.isPresent()) { + event.setCancelled(true); + event.setReason(PunishmentService.disconnectMessage(ban.get())); + } + } catch (SQLException e) { + ((HytaleLogger.Api) LOGGER.at(Level.WARNING).withCause(e)) + .log("Ban lookup failed for %s (%s); allowing connect", event.getUsername(), event.getUuid()); + } + } +} diff --git a/src/main/java/net/kewwbec/staff/listener/ChatEnforcer.java b/src/main/java/net/kewwbec/staff/listener/ChatEnforcer.java new file mode 100644 index 0000000..22437f1 --- /dev/null +++ b/src/main/java/net/kewwbec/staff/listener/ChatEnforcer.java @@ -0,0 +1,32 @@ +package net.kewwbec.staff.listener; + +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.event.events.player.PlayerChatEvent; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import net.kewwbec.staff.model.Punishment; +import net.kewwbec.staff.service.MuteCache; +import net.kewwbec.staff.util.DurationParser; + +import javax.annotation.Nonnull; +import java.awt.Color; + +public final class ChatEnforcer { + + private final MuteCache cache; + + public ChatEnforcer(@Nonnull MuteCache cache) { + this.cache = cache; + } + + public void onChat(@Nonnull PlayerChatEvent event) { + PlayerRef sender = event.getSender(); + if (sender == null) return; + Punishment mute = cache.get(sender.getUuid()); + if (mute == null) return; + event.setCancelled(true); + String suffix = (mute.expiresAt() == null) + ? "permanently" + : "for " + DurationParser.formatRemaining(mute.expiresAt() - System.currentTimeMillis()); + sender.sendMessage(Message.raw("You are muted " + suffix + ": " + mute.reason()).color(Color.RED)); + } +} diff --git a/src/main/java/net/kewwbec/staff/listener/PlayerSeenTracker.java b/src/main/java/net/kewwbec/staff/listener/PlayerSeenTracker.java new file mode 100644 index 0000000..bf0124a --- /dev/null +++ b/src/main/java/net/kewwbec/staff/listener/PlayerSeenTracker.java @@ -0,0 +1,57 @@ +package net.kewwbec.staff.listener; + +import com.hypixel.hytale.logger.HytaleLogger; +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.staff.db.PlayerSeenRepository; +import net.kewwbec.staff.service.MuteCache; +import net.kewwbec.staff.service.PunishmentService; + +import javax.annotation.Nonnull; +import java.sql.SQLException; +import java.util.logging.Level; + +public final class PlayerSeenTracker { + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + private final PlayerSeenRepository seen; + private final MuteCache muteCache; + private final PunishmentService punishments; + private final String serverId; + + public PlayerSeenTracker(@Nonnull PlayerSeenRepository seen, + @Nonnull MuteCache muteCache, + @Nonnull PunishmentService punishments, + @Nonnull String serverId) { + this.seen = seen; + this.muteCache = muteCache; + this.punishments = punishments; + this.serverId = serverId; + } + + public void onConnect(@Nonnull PlayerConnectEvent event) { + PlayerRef ref = event.getPlayerRef(); + if (ref == null) return; + try { + seen.recordJoin(ref.getUuid(), ref.getUsername(), serverId, System.currentTimeMillis()); + } catch (SQLException e) { + ((HytaleLogger.Api) LOGGER.at(Level.WARNING).withCause(e)) + .log("Failed to record join for %s", ref.getUuid()); + } + punishments.loadMuteIntoCache(ref.getUuid()); + } + + public void onDisconnect(@Nonnull PlayerDisconnectEvent event) { + PlayerRef ref = event.getPlayerRef(); + if (ref == null) return; + try { + seen.recordLeave(ref.getUuid(), System.currentTimeMillis()); + } catch (SQLException e) { + ((HytaleLogger.Api) LOGGER.at(Level.WARNING).withCause(e)) + .log("Failed to record leave for %s", ref.getUuid()); + } + muteCache.remove(ref.getUuid()); + } +} diff --git a/src/main/java/net/kewwbec/staff/model/PlayerSeen.java b/src/main/java/net/kewwbec/staff/model/PlayerSeen.java new file mode 100644 index 0000000..3f00dd6 --- /dev/null +++ b/src/main/java/net/kewwbec/staff/model/PlayerSeen.java @@ -0,0 +1,15 @@ +package net.kewwbec.staff.model; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.UUID; + +public record PlayerSeen( + @Nonnull UUID uuid, + @Nonnull String lastName, + long firstSeen, + long lastSeen, + @Nullable String lastServerId, + boolean online +) { +} diff --git a/src/main/java/net/kewwbec/staff/model/Punishment.java b/src/main/java/net/kewwbec/staff/model/Punishment.java new file mode 100644 index 0000000..ec53074 --- /dev/null +++ b/src/main/java/net/kewwbec/staff/model/Punishment.java @@ -0,0 +1,33 @@ +package net.kewwbec.staff.model; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.UUID; + +public record Punishment( + long id, + @Nonnull UUID targetUuid, + @Nonnull String targetName, + @Nonnull PunishmentType type, + @Nonnull String reason, + @Nonnull UUID issuedByUuid, + @Nonnull String issuedByName, + long issuedAt, + @Nullable Long expiresAt, + @Nonnull String issuedOnServer, + boolean active, + @Nullable Long revokedAt, + @Nullable UUID revokedByUuid, + @Nullable String revokedByName, + @Nullable String revokeReason +) { + public boolean isExpired(long now) { + return expiresAt != null && expiresAt <= now; + } + + public boolean isCurrentlyEffective(long now) { + if (!active) return false; + if (revokedAt != null) return false; + return !isExpired(now); + } +} diff --git a/src/main/java/net/kewwbec/staff/model/PunishmentType.java b/src/main/java/net/kewwbec/staff/model/PunishmentType.java new file mode 100644 index 0000000..476c247 --- /dev/null +++ b/src/main/java/net/kewwbec/staff/model/PunishmentType.java @@ -0,0 +1,30 @@ +package net.kewwbec.staff.model; + +import javax.annotation.Nonnull; + +public enum PunishmentType { + BAN(true, false), + TEMPBAN(true, true), + MUTE(true, false), + TEMPMUTE(true, true), + KICK(false, false), + WARN(false, false); + + private final boolean persistent; + private final boolean timed; + + PunishmentType(boolean persistent, boolean timed) { + this.persistent = persistent; + this.timed = timed; + } + + public boolean isPersistent() { return persistent; } + public boolean isTimed() { return timed; } + public boolean isBan() { return this == BAN || this == TEMPBAN; } + public boolean isMute() { return this == MUTE || this == TEMPMUTE; } + + @Nonnull + public static PunishmentType fromString(@Nonnull String s) { + return PunishmentType.valueOf(s.trim().toUpperCase()); + } +} diff --git a/src/main/java/net/kewwbec/staff/service/MuteCache.java b/src/main/java/net/kewwbec/staff/service/MuteCache.java new file mode 100644 index 0000000..640d1d0 --- /dev/null +++ b/src/main/java/net/kewwbec/staff/service/MuteCache.java @@ -0,0 +1,37 @@ +package net.kewwbec.staff.service; + +import net.kewwbec.staff.model.Punishment; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public final class MuteCache { + + private final Map active = new ConcurrentHashMap<>(); + + public void put(@Nonnull UUID uuid, @Nonnull Punishment punishment) { + active.put(uuid, punishment); + } + + public void remove(@Nonnull UUID uuid) { + active.remove(uuid); + } + + @Nullable + public Punishment get(@Nonnull UUID uuid) { + Punishment p = active.get(uuid); + if (p == null) return null; + if (!p.isCurrentlyEffective(System.currentTimeMillis())) { + active.remove(uuid); + return null; + } + return p; + } + + public boolean isMuted(@Nonnull UUID uuid) { + return get(uuid) != null; + } +} diff --git a/src/main/java/net/kewwbec/staff/service/PunishmentBroadcast.java b/src/main/java/net/kewwbec/staff/service/PunishmentBroadcast.java new file mode 100644 index 0000000..f8ab508 --- /dev/null +++ b/src/main/java/net/kewwbec/staff/service/PunishmentBroadcast.java @@ -0,0 +1,23 @@ +package net.kewwbec.staff.service; + +import javax.annotation.Nullable; + +public final class PunishmentBroadcast { + + public static final String CHANNEL = "staff.punishments"; + public static final String ACTION_ISSUED = "issued"; + public static final String ACTION_REVOKED = "revoked"; + + public String action; + public long id; + public String targetUuid; + public String targetName; + public String type; + public String reason; + public String issuedByUuid; + public String issuedByName; + public long issuedAt; + @Nullable public Long expiresAt; + public String issuedOnServer; + @Nullable public String revokeReason; +} diff --git a/src/main/java/net/kewwbec/staff/service/PunishmentService.java b/src/main/java/net/kewwbec/staff/service/PunishmentService.java new file mode 100644 index 0000000..3bf0f3c --- /dev/null +++ b/src/main/java/net/kewwbec/staff/service/PunishmentService.java @@ -0,0 +1,225 @@ +package net.kewwbec.staff.service; + +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 net.kewwbec.networkcore.NetworkCore; +import net.kewwbec.networkcore.api.MessageBus; +import net.kewwbec.staff.db.PunishmentRepository; +import net.kewwbec.staff.model.Punishment; +import net.kewwbec.staff.model.PunishmentType; +import net.kewwbec.staff.util.DurationParser; + +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; + +public final class PunishmentService { + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + private final PunishmentRepository repo; + private final MuteCache muteCache; + private final MessageBus bus; + private final String serverId; + @Nullable private MessageBus.Subscription subscription; + + public PunishmentService(@Nonnull PunishmentRepository repo, + @Nonnull MuteCache muteCache, + @Nonnull MessageBus bus, + @Nonnull String serverId) { + this.repo = repo; + this.muteCache = muteCache; + this.bus = bus; + this.serverId = serverId; + } + + public void start() { + this.subscription = bus.subscribe(PunishmentBroadcast.CHANNEL, PunishmentBroadcast.class, this::onRemote); + } + + public void stop() { + if (subscription != null) { + try { subscription.close(); } catch (RuntimeException ignored) {} + subscription = null; + } + } + + @Nonnull + public Punishment issue(@Nonnull UUID targetUuid, + @Nonnull String targetName, + @Nonnull PunishmentType type, + @Nonnull String reason, + @Nonnull UUID issuerUuid, + @Nonnull String issuerName, + @Nullable Long durationMs) throws SQLException { + long now = System.currentTimeMillis(); + Long expiresAt = (type.isTimed() && durationMs != null) ? now + durationMs : null; + Punishment incoming = new Punishment( + -1L, + targetUuid, targetName, + type, reason, + issuerUuid, issuerName, + now, expiresAt, + serverId, + type.isPersistent(), + null, null, null, null + ); + long id = repo.insert(incoming); + Punishment saved = new Punishment( + id, + targetUuid, targetName, + type, reason, + issuerUuid, issuerName, + now, expiresAt, + serverId, + type.isPersistent(), + null, null, null, null + ); + + applyLocally(saved); + publishIssued(saved); + return saved; + } + + /** + * Revokes the active ban/mute of the given type for the target. Returns the number of rows updated. + */ + public int revoke(@Nonnull UUID target, + @Nonnull PunishmentType type, + @Nonnull UUID revokerUuid, + @Nonnull String revokerName, + @Nullable String reason) throws SQLException { + long now = System.currentTimeMillis(); + int updated = repo.revokeActiveOfType(target, type, revokerUuid, revokerName, reason, now); + if (updated > 0) { + publishRevoked(target, type, revokerName, reason); + if (type.isMute()) { + muteCache.remove(target); + } + } + return updated; + } + + @Nonnull + public Optional getActiveBan(@Nonnull UUID target) throws SQLException { + return repo.findActiveBan(target, System.currentTimeMillis()); + } + + @Nonnull + public Optional getActiveMute(@Nonnull UUID target) throws SQLException { + return repo.findActiveMute(target, System.currentTimeMillis()); + } + + public void loadMuteIntoCache(@Nonnull UUID uuid) { + try { + getActiveMute(uuid).ifPresent(p -> muteCache.put(uuid, p)); + } catch (SQLException e) { + ((HytaleLogger.Api) LOGGER.at(Level.WARNING).withCause(e)).log("Failed to load mute for %s", uuid); + } + } + + private void applyLocally(@Nonnull Punishment p) { + if (p.type().isMute()) { + muteCache.put(p.targetUuid(), p); + return; + } + if (p.type().isBan() || p.type() == PunishmentType.KICK) { + PlayerRef target = findOnline(p.targetUuid()); + if (target != null) { + target.getPacketHandler().disconnect(disconnectMessage(p)); + } + return; + } + if (p.type() == PunishmentType.WARN) { + PlayerRef target = findOnline(p.targetUuid()); + if (target != null) { + target.sendMessage(Message.raw("You were warned by " + p.issuedByName() + ": " + p.reason())); + } + } + } + + private void onRemote(@Nonnull PunishmentBroadcast b) { + if (b.issuedOnServer != null && b.issuedOnServer.equals(serverId)) return; + if (b.targetUuid == null || b.action == null) return; + UUID targetUuid; + try { targetUuid = UUID.fromString(b.targetUuid); } catch (IllegalArgumentException e) { return; } + + if (PunishmentBroadcast.ACTION_REVOKED.equals(b.action)) { + muteCache.remove(targetUuid); + return; + } + + if (!PunishmentBroadcast.ACTION_ISSUED.equals(b.action)) return; + + PunishmentType type; + try { type = PunishmentType.fromString(b.type); } catch (Exception e) { return; } + + Punishment p = new Punishment( + b.id, + targetUuid, + b.targetName == null ? "" : b.targetName, + type, + b.reason == null ? "" : b.reason, + UUID.fromString(b.issuedByUuid), + b.issuedByName == null ? "" : b.issuedByName, + b.issuedAt, + b.expiresAt, + b.issuedOnServer == null ? "" : b.issuedOnServer, + true, null, null, null, null + ); + applyLocally(p); + } + + private void publishIssued(@Nonnull Punishment p) { + PunishmentBroadcast b = new PunishmentBroadcast(); + b.action = PunishmentBroadcast.ACTION_ISSUED; + b.id = p.id(); + b.targetUuid = p.targetUuid().toString(); + b.targetName = p.targetName(); + b.type = p.type().name(); + b.reason = p.reason(); + b.issuedByUuid = p.issuedByUuid().toString(); + b.issuedByName = p.issuedByName(); + b.issuedAt = p.issuedAt(); + b.expiresAt = p.expiresAt(); + b.issuedOnServer = p.issuedOnServer(); + bus.publish(PunishmentBroadcast.CHANNEL, b); + } + + private void publishRevoked(@Nonnull UUID target, @Nonnull PunishmentType type, @Nonnull String revokerName, @Nullable String reason) { + PunishmentBroadcast b = new PunishmentBroadcast(); + b.action = PunishmentBroadcast.ACTION_REVOKED; + b.targetUuid = target.toString(); + b.type = type.name(); + b.issuedByName = revokerName; + b.issuedOnServer = serverId; + b.revokeReason = reason; + bus.publish(PunishmentBroadcast.CHANNEL, b); + } + + @Nullable + private PlayerRef findOnline(@Nonnull UUID uuid) { + Universe universe = Universe.get(); + if (universe == null) return null; + return universe.getPlayer(uuid); + } + + @Nonnull + public static String disconnectMessage(@Nonnull Punishment p) { + StringBuilder sb = new StringBuilder(); + if (p.type() == PunishmentType.KICK) { + sb.append("Kicked: "); + } else if (p.type() == PunishmentType.TEMPBAN) { + sb.append("Banned for ").append(DurationParser.formatRemaining(p.expiresAt() == null ? 0 : p.expiresAt() - System.currentTimeMillis())).append(": "); + } else { + sb.append("Banned: "); + } + sb.append(p.reason()); + return sb.toString(); + } +} diff --git a/src/main/java/net/kewwbec/staff/service/StaffChatPayload.java b/src/main/java/net/kewwbec/staff/service/StaffChatPayload.java new file mode 100644 index 0000000..3ea15cb --- /dev/null +++ b/src/main/java/net/kewwbec/staff/service/StaffChatPayload.java @@ -0,0 +1,10 @@ +package net.kewwbec.staff.service; + +public final class StaffChatPayload { + + public String fromServerId; + public String fromUsername; + public String message; + public long ts; + public boolean systemAnnouncement; +} diff --git a/src/main/java/net/kewwbec/staff/service/StaffChatService.java b/src/main/java/net/kewwbec/staff/service/StaffChatService.java new file mode 100644 index 0000000..eea721b --- /dev/null +++ b/src/main/java/net/kewwbec/staff/service/StaffChatService.java @@ -0,0 +1,87 @@ +package net.kewwbec.staff.service; + +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.Universe; +import net.kewwbec.networkcore.api.MessageBus; +import net.kewwbec.staff.config.StaffConfig; +import net.kewwbec.staff.util.StaffPermissions; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.awt.Color; + +public final class StaffChatService { + + private final MessageBus bus; + private final StaffConfig.ChatSection cfg; + private final StaffPermissions perms; + private final String serverId; + + @Nullable private MessageBus.Subscription subscription; + + public StaffChatService(@Nonnull MessageBus bus, + @Nonnull StaffConfig.ChatSection cfg, + @Nonnull StaffPermissions perms, + @Nonnull String serverId) { + this.bus = bus; + this.cfg = cfg; + this.perms = perms; + this.serverId = serverId; + } + + public void start() { + this.subscription = bus.subscribe(cfg.channel, StaffChatPayload.class, this::onReceive); + } + + public void stop() { + if (subscription != null) { + try { subscription.close(); } catch (RuntimeException ignored) {} + subscription = null; + } + } + + public void send(@Nonnull PlayerRef sender, @Nonnull String message) { + StaffChatPayload p = new StaffChatPayload(); + p.fromServerId = serverId; + p.fromUsername = sender.getUsername(); + p.message = message; + p.ts = System.currentTimeMillis(); + p.systemAnnouncement = false; + bus.publish(cfg.channel, p); + } + + /** + * Broadcast a system message (no player sender). Used by punishment/system events. + */ + public void announce(@Nonnull String text) { + StaffChatPayload p = new StaffChatPayload(); + p.fromServerId = serverId; + p.fromUsername = "SYSTEM"; + p.message = text; + p.ts = System.currentTimeMillis(); + p.systemAnnouncement = true; + bus.publish(cfg.channel, p); + } + + private void onReceive(@Nonnull StaffChatPayload p) { + if (p.message == null) return; + Universe universe = Universe.get(); + if (universe == null) return; + + String formatted = formatLine(p); + for (PlayerRef ref : universe.getPlayers()) { + if (!perms.isStaff(ref)) continue; + ref.sendMessage(Message.raw(formatted).color(p.systemAnnouncement ? Color.YELLOW : Color.LIGHT_GRAY)); + } + } + + @Nonnull + private String formatLine(@Nonnull StaffChatPayload p) { + String origin = (p.fromServerId == null) ? "?" : p.fromServerId; + if (p.systemAnnouncement) { + return cfg.prefix + " " + origin + " | " + p.message; + } + return cfg.prefix + " " + p.fromUsername + " (" + origin + "): " + p.message; + } +} diff --git a/src/main/java/net/kewwbec/staff/util/DurationParser.java b/src/main/java/net/kewwbec/staff/util/DurationParser.java new file mode 100644 index 0000000..a9fc952 --- /dev/null +++ b/src/main/java/net/kewwbec/staff/util/DurationParser.java @@ -0,0 +1,51 @@ +package net.kewwbec.staff.util; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public final class DurationParser { + + private DurationParser() { + } + + /** + * Parses durations like "30s", "5m", "2h", "7d", "1w". Returns millis, or null if the input is invalid. + */ + @Nullable + public static Long parseMillis(@Nonnull String input) { + if (input == null || input.isBlank()) return null; + String s = input.trim().toLowerCase(); + char unit = s.charAt(s.length() - 1); + String numPart = s.substring(0, s.length() - 1); + long n; + try { + n = Long.parseLong(numPart); + } catch (NumberFormatException e) { + return null; + } + if (n <= 0) return null; + return switch (unit) { + case 's' -> n * 1_000L; + case 'm' -> n * 60_000L; + case 'h' -> n * 3_600_000L; + case 'd' -> n * 86_400_000L; + case 'w' -> n * 7L * 86_400_000L; + default -> null; + }; + } + + @Nonnull + public static String formatRemaining(long millis) { + if (millis <= 0) return "expired"; + long s = millis / 1000L; + long days = s / 86400L; s %= 86400L; + long hours = s / 3600L; s %= 3600L; + long mins = s / 60L; s %= 60L; + StringBuilder sb = new StringBuilder(); + if (days > 0) sb.append(days).append("d "); + if (hours > 0) sb.append(hours).append("h "); + if (mins > 0) sb.append(mins).append("m "); + if (sb.length() == 0) sb.append(s).append("s"); + return sb.toString().trim(); + } +} diff --git a/src/main/java/net/kewwbec/staff/util/PlayerResolver.java b/src/main/java/net/kewwbec/staff/util/PlayerResolver.java new file mode 100644 index 0000000..05572db --- /dev/null +++ b/src/main/java/net/kewwbec/staff/util/PlayerResolver.java @@ -0,0 +1,66 @@ +package net.kewwbec.staff.util; + +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.Universe; +import net.kewwbec.staff.db.PlayerSeenRepository; +import net.kewwbec.staff.model.PlayerSeen; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.sql.SQLException; +import java.util.Optional; +import java.util.UUID; + +public final class PlayerResolver { + + private final PlayerSeenRepository seenRepo; + + public PlayerResolver(@Nonnull PlayerSeenRepository seenRepo) { + this.seenRepo = seenRepo; + } + + /** + * Try to resolve a player by name. Looks at online players first, then the players_seen table. + * Returns a Resolved with the canonical UUID + name. Returns null if no match anywhere. + */ + @Nullable + public Resolved byName(@Nonnull String nameRaw) throws SQLException { + if (nameRaw == null || nameRaw.isBlank()) return null; + String name = nameRaw.trim(); + + PlayerRef onlineRef = findOnlineByName(name); + if (onlineRef != null) { + return new Resolved(onlineRef.getUuid(), onlineRef.getUsername(), onlineRef); + } + + Optional seen = seenRepo.findByName(name); + return seen.map(s -> new Resolved(s.uuid(), s.lastName(), null)).orElse(null); + } + + @Nullable + public Resolved byUuid(@Nonnull UUID uuid) throws SQLException { + Universe universe = Universe.get(); + if (universe != null) { + PlayerRef online = universe.getPlayer(uuid); + if (online != null) { + return new Resolved(uuid, online.getUsername(), online); + } + } + Optional seen = seenRepo.findByUuid(uuid); + return seen.map(s -> new Resolved(s.uuid(), s.lastName(), null)).orElse(null); + } + + @Nullable + private PlayerRef findOnlineByName(@Nonnull String name) { + Universe universe = Universe.get(); + if (universe == null) return null; + for (PlayerRef ref : universe.getPlayers()) { + if (ref.getUsername().equalsIgnoreCase(name)) return ref; + } + return null; + } + + public record Resolved(@Nonnull UUID uuid, @Nonnull String name, @Nullable PlayerRef onlineRef) { + public boolean isOnline() { return onlineRef != null; } + } +} diff --git a/src/main/java/net/kewwbec/staff/util/StaffPermissions.java b/src/main/java/net/kewwbec/staff/util/StaffPermissions.java new file mode 100644 index 0000000..c049ac7 --- /dev/null +++ b/src/main/java/net/kewwbec/staff/util/StaffPermissions.java @@ -0,0 +1,46 @@ +package net.kewwbec.staff.util; + +import com.hypixel.hytale.server.core.permissions.PermissionsModule; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import net.kewwbec.staff.config.StaffConfig; + +import javax.annotation.Nonnull; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +public final class StaffPermissions { + + private final StaffConfig.PermissionsSection cfg; + + public StaffPermissions(@Nonnull StaffConfig.PermissionsSection cfg) { + this.cfg = cfg; + } + + public boolean isStaff(@Nonnull PlayerRef player) { + return isStaff(player.getUuid()); + } + + public boolean isStaff(@Nonnull UUID uuid) { + return matchesAny(uuid, cfg.staff_groups); + } + + public boolean isAdmin(@Nonnull PlayerRef player) { + return isAdmin(player.getUuid()); + } + + public boolean isAdmin(@Nonnull UUID uuid) { + return matchesAny(uuid, cfg.admin_groups); + } + + private boolean matchesAny(@Nonnull UUID uuid, @Nonnull List groups) { + PermissionsModule module = PermissionsModule.get(); + if (module == null) return false; + Set playerGroups = module.getGroupsForUser(uuid); + if (playerGroups == null || playerGroups.isEmpty()) return false; + for (String allowed : groups) { + if (playerGroups.contains(allowed)) return true; + } + return false; + } +} diff --git a/src/main/resources/manifest.json b/src/main/resources/manifest.json new file mode 100644 index 0000000..79ff58e --- /dev/null +++ b/src/main/resources/manifest.json @@ -0,0 +1,17 @@ +{ + "Group": "kewwbec", + "Name": "Staff", + "Version": "0.1.0", + "Description": "Staff tools: punishments, history, staff chat, player lookup", + "IncludesAssetsPack": false, + "Authors": [ + {"Name": "kewwbec"} + ], + "ServerVersion": "*", + "Dependencies": { + "kewwbec:NetworkCore": "*" + }, + "OptionalDependencies": {}, + "DisabledByDefault": false, + "Main": "net.kewwbec.staff.StaffPlugin" +}