This commit is contained in:
2026-05-26 00:59:39 -04:00
commit 4f4f731bc7
35 changed files with 2417 additions and 0 deletions
+30
View File
@@ -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
+41
View File
@@ -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 <player>`.
- **Staff chat**: `/sc <message>` broadcasts to every staff member online on any server.
- **Player lookup**: `/lookup <player>` 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.
+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
+51
View File
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>net.kewwbec</groupId>
<artifactId>staff</artifactId>
<version>0.1.0</version>
<packaging>jar</packaging>
<name>Staff</name>
<description>KweebecNet staff tools: punishments, history, staff chat, player lookup</description>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<hytale.server.jar>${user.home}/AppData/Roaming/Hytale/install/release/package/game/latest/Server/HytaleServer.jar</hytale.server.jar>
<networkcore.jar>${project.basedir}/../Core/target/NetworkCore-0.1.0.jar</networkcore.jar>
</properties>
<dependencies>
<dependency>
<groupId>com.hypixel.hytale</groupId>
<artifactId>hytale-server</artifactId>
<version>local</version>
<scope>system</scope>
<systemPath>${hytale.server.jar}</systemPath>
</dependency>
<dependency>
<groupId>net.kewwbec</groupId>
<artifactId>networkcore</artifactId>
<version>0.1.0</version>
<scope>system</scope>
<systemPath>${networkcore.jar}</systemPath>
</dependency>
<dependency>
<groupId>com.google.code.findbugs</groupId>
<artifactId>jsr305</artifactId>
<version>3.0.2</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}-${project.version}</finalName>
</build>
</project>
@@ -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");
}
}
@@ -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<String> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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 <message>").color(Color.YELLOW));
return;
}
chat.send(sender, message);
}
}
@@ -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<String> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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 <player>").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<Punishment> 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));
}
}
}
@@ -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<String> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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 <player>").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<PlayerSeen> 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<Punishment> 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<Punishment> 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));
}
}
}
@@ -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<String> targetArg = withRequiredArg("player", "Target player", ArgTypes.STRING);
private final RequiredArg<String> 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<EntityStore> store, @Nonnull Ref<EntityStore> 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 <player> <reason>").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<String> targetArg = withRequiredArg("player", "Target player", ArgTypes.STRING);
private final RequiredArg<String> durationArg = withRequiredArg("duration", "e.g. 30m, 2h, 7d", ArgTypes.STRING);
private final RequiredArg<String> reasonArg = withRequiredArg("reason", "Reason", ArgTypes.STRING);
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<EntityStore> store, @Nonnull Ref<EntityStore> 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 <player> <duration> <reason>").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<String> targetArg = withRequiredArg("player", "Target player", ArgTypes.STRING);
private final RequiredArg<String> 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<EntityStore> store, @Nonnull Ref<EntityStore> 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 <player> <reason>").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<String> targetArg = withRequiredArg("player", "Target player", ArgTypes.STRING);
private final RequiredArg<String> durationArg = withRequiredArg("duration", "e.g. 30m, 2h", ArgTypes.STRING);
private final RequiredArg<String> reasonArg = withRequiredArg("reason", "Reason", ArgTypes.STRING);
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<EntityStore> store, @Nonnull Ref<EntityStore> 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 <player> <duration> <reason>").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<String> targetArg = withRequiredArg("player", "Target player", ArgTypes.STRING);
private final RequiredArg<String> 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<EntityStore> store, @Nonnull Ref<EntityStore> 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 <player> <reason>").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<String> targetArg = withRequiredArg("player", "Target player", ArgTypes.STRING);
private final RequiredArg<String> 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<EntityStore> store, @Nonnull Ref<EntityStore> 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 <player> <reason>").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<String> 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<EntityStore> store, @Nonnull Ref<EntityStore> 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 <player>").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<String> 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<EntityStore> store, @Nonnull Ref<EntityStore> 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 <player>").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));
}
}
}
}
@@ -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<String> staff_groups = new ArrayList<>(List.of("OP", "ADMIN", "MOD", "HELPER"));
public List<String> 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";
}
}
@@ -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;
}
}
@@ -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<PlayerSeen> 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<PlayerSeen> 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")
);
}
}
@@ -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<Punishment> 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<Punishment> findActiveBan(@Nonnull UUID target, long now) throws SQLException {
Optional<Punishment> ban = findActiveByTargetAndType(target, PunishmentType.BAN, now);
if (ban.isPresent()) return ban;
return findActiveByTargetAndType(target, PunishmentType.TEMPBAN, now);
}
@Nonnull
public Optional<Punishment> findActiveMute(@Nonnull UUID target, long now) throws SQLException {
Optional<Punishment> mute = findActiveByTargetAndType(target, PunishmentType.MUTE, now);
if (mute.isPresent()) return mute;
return findActiveByTargetAndType(target, PunishmentType.TEMPMUTE, now);
}
@Nonnull
public List<Punishment> findHistory(@Nonnull UUID target, int limit) throws SQLException {
String sql = "SELECT * FROM " + table + " WHERE target_uuid = ? ORDER BY issued_at DESC LIMIT ?";
List<Punishment> 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")
);
}
}
@@ -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);
}
}
}
@@ -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<Punishment> 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());
}
}
}
@@ -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));
}
}
@@ -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());
}
}
@@ -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
) {
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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<UUID, Punishment> 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;
}
}
@@ -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;
}
@@ -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<Punishment> getActiveBan(@Nonnull UUID target) throws SQLException {
return repo.findActiveBan(target, System.currentTimeMillis());
}
@Nonnull
public Optional<Punishment> 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();
}
}
@@ -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;
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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<PlayerSeen> 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<PlayerSeen> 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; }
}
}
@@ -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<String> groups) {
PermissionsModule module = PermissionsModule.get();
if (module == null) return false;
Set<String> playerGroups = module.getGroupsForUser(uuid);
if (playerGroups == null || playerGroups.isEmpty()) return false;
for (String allowed : groups) {
if (playerGroups.contains(allowed)) return true;
}
return false;
}
}
+17
View File
@@ -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"
}