This commit is contained in:
2026-05-26 00:59:39 -04:00
commit 4f4f731bc7
35 changed files with 2417 additions and 0 deletions
+65
View File
@@ -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.
+61
View File
@@ -0,0 +1,61 @@
# Configuration
## Location
`<plugin data dir>/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 <staff-member-name>` 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.
+71
View File
@@ -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 &lt;player&gt; &lt;reason&gt;
Permanent ban. Active across all servers in the network within a few hundred ms. Online target is disconnected immediately with the reason.
### /tempban &lt;player&gt; &lt;duration&gt; &lt;reason&gt;
Time-limited ban. Duration syntax: `30s`, `5m`, `2h`, `7d`, `1w`. Expires automatically (the active-ban query filters by `expires_at`).
### /mute &lt;player&gt; &lt;reason&gt;
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 &lt;player&gt; &lt;duration&gt; &lt;reason&gt;
Time-limited mute. Same duration syntax as tempban.
### /kick &lt;player&gt; &lt;reason&gt;
One-shot disconnect. Recorded in history but doesn't persist beyond that (the target can rejoin immediately).
### /warn &lt;player&gt; &lt;reason&gt;
Recorded in history and shown to the target as a message if they're online. No connection or chat impact.
### /unban &lt;player&gt;
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 &lt;player&gt;
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 &lt;message&gt;
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 &lt;player&gt;
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 &lt;player&gt;
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. |
+94
View File
@@ -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.
+57
View File
@@ -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.
+77
View File
@@ -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 <your-main>`. You should see your own UUID and online status.
3. Connect a non-staff alt. Have a staff member run `/warn <alt> 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 <theirname>` - 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).
+8
View File
@@ -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