init
This commit is contained in:
+30
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Commands
|
||||
|
||||
All commands require the caller to be in one of the `staff_groups` from config. Player-only (no console support yet).
|
||||
|
||||
## Punishments
|
||||
|
||||
### /ban <player> <reason>
|
||||
|
||||
Permanent ban. Active across all servers in the network within a few hundred ms. Online target is disconnected immediately with the reason.
|
||||
|
||||
### /tempban <player> <duration> <reason>
|
||||
|
||||
Time-limited ban. Duration syntax: `30s`, `5m`, `2h`, `7d`, `1w`. Expires automatically (the active-ban query filters by `expires_at`).
|
||||
|
||||
### /mute <player> <reason>
|
||||
|
||||
Permanent mute. Cancels `PlayerChatEvent` for the target on every server. Loaded into a per-server in-memory cache on the target's connect so chat enforcement doesn't hit MySQL on every message.
|
||||
|
||||
### /tempmute <player> <duration> <reason>
|
||||
|
||||
Time-limited mute. Same duration syntax as tempban.
|
||||
|
||||
### /kick <player> <reason>
|
||||
|
||||
One-shot disconnect. Recorded in history but doesn't persist beyond that (the target can rejoin immediately).
|
||||
|
||||
### /warn <player> <reason>
|
||||
|
||||
Recorded in history and shown to the target as a message if they're online. No connection or chat impact.
|
||||
|
||||
### /unban <player>
|
||||
|
||||
Revokes any active ban or tempban for the player. Marks the punishment row as `active=0`, fills `revoked_at` / `revoked_by_*`. Original row stays in history.
|
||||
|
||||
### /unmute <player>
|
||||
|
||||
Same as unban but for mutes. Also clears the entry from the mute cache on any server where the player is online.
|
||||
|
||||
## Staff chat
|
||||
|
||||
### /sc <message>
|
||||
|
||||
Broadcasts on the cross-server staff chat channel. Visible only to other players whose `getGroupsForUser` overlaps with `staff_groups`.
|
||||
|
||||
System events (a ban being issued, an unban) also post a line on this channel automatically.
|
||||
|
||||
## Lookup
|
||||
|
||||
### /lookup <player>
|
||||
|
||||
Shows:
|
||||
- Player's last-known name and UUID
|
||||
- Online status (and which server they're on, if any)
|
||||
- First-seen and last-seen timestamps
|
||||
- Any active ban
|
||||
- Any active mute
|
||||
|
||||
If the player has never connected, only the name/UUID lookup happens via online players and may return nothing.
|
||||
|
||||
### /history <player>
|
||||
|
||||
Last 20 punishment entries for the player, most recent first. Each entry shows id, state (`ACTIVE` / `EXPIRED` / `REVOKED`), type, who issued it, when, and the reason.
|
||||
|
||||
## Common error cases
|
||||
|
||||
| Error message | Cause |
|
||||
|---|---|
|
||||
| "You do not have permission to use this command." | Caller isn't in any of `staff_groups`. |
|
||||
| "No player found with that name (online or in history)." | Name doesn't match any online player or any name in `staff_players_seen`. |
|
||||
| "Database error: ..." | MySQL query failed. Check NetworkCore connection (`/networkcore status`). |
|
||||
| "Bad duration. Use 30s, 5m, 2h, 7d, 1w." | Duration string didn't parse. Last char must be `s`/`m`/`h`/`d`/`w`, prefix must be a positive integer. |
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user