AstralPunishments Overview
AstralPunishments is the network's punishment system — bans, IP bans, mutes, warns and kicks. It ships as three modules that share one MariaDB schema and one Redis-backed messaging channel:
velocity— a Velocity plugin (depends onastralcore) that owns every punishment command (/ban,/ipban,/mute,/warn,/unwarn,/kick,/unban,/unmute), enforces bans at proxy login, applies immunity and duration limits, delivers warnings, and posts Discord webhooks.paper— a Paper plugin that enforces mutes on the server and owns the staff-facing GUIs (/lookup,/history,/alts, the in-force listings) plus the placeholders that render them. It never issues punishments.master— a MageHic plugin serving the punishment record over HTTP, read-only. See HTTP API.
velocity and paper read and write the same punishments table through their own independent PunishmentRepository/PunishmentService pair — there is no RPC between the two for CRUD; they're kept in sync purely by sharing the database and by broadcasting cache-invalidation packets.
Punishment types
PunishmentType (common/model/PunishmentType.java) has five values: BAN, IPBAN, MUTE, KICK, WARN. They fall into two behavioral groups:
Stateful (
BAN,MUTE,IPBAN) — enforce an ongoing state, so at most one active punishment of that type exists per target at a time. Issuing the same type again while one is already active reissues the existing row (PunishmentEntity#reissue) instead of creating a duplicate — the id and its pardon history are preserved, only the issuer, reason, start time, expiry (and, for IP bans, the address) are refreshed.Point-in-time (
KICK,WARN) — a one-off record; a new row is always created.
BAN, IPBAN and MUTE are lifted with /unban//unmute or the GUI's pardon button; a WARN is lifted with /unwarn or the same button. A KICK is historical only — there is nothing to lift.
Enforcement
Type | Enforced by | Where |
|---|---|---|
|
| velocity |
|
| paper |
| Delivered to the player ( | velocity |
| The disconnect itself | velocity |
Mute enforcement
A muted player's chat never reaches the chat plugin: MuteListener cancels AsyncChatEvent at EventPriority.LOWEST, before anything formats, filters, logs or relays it, and answers with the chat-muted message. The same listener refuses the commands listed in mute.yml — the ones that would otherwise let a muted player keep talking (private messages, channel commands, /me) — with command-muted. Command matching is case-insensitive and ignores the plugin namespace, so msg also covers /MSG and /astralchat:msg; aliases are not implied and must be listed individually.
Both paths must answer synchronously, so they read a per-player cache rather than the database. PlayerConnectionListener warms that cache on AsyncPlayerPreLoginEvent (waiting up to 5 seconds); a timeout never denies the connection — the player joins and the mute check fails open for them, logging a warning. A cache miss for an account that was loaded is a genuine "not muted".
Login enforcement
BanListener (velocity, PreLoginEvent) returns an EventTask rather than blocking, so the database round-trip never stalls the event thread. For every connection attempt another listener hasn't already denied, it calls PunishmentService#findActiveBan(playerId, ip), which returns whichever of an active account BAN for the player or an active IPBAN on the connecting address was issued more recently — pardoned or expired rows never block, and there is no local caching, so a network-wide unban takes effect on the next attempt. A lookup failure fails open: the connection is allowed rather than the whole network being locked out.
The denial screen is player-ipbanned for an IP ban or player-banned for an account ban, rendered with %reason% and %duration% ("Permanent" or a human-readable remaining time).
Warning delivery
A warning the player never sees does nothing, so the delivered column tracks whether it has been shown. A warning issued while the target is connected is delivered immediately; one issued while they are offline stays undelivered until WarningListener sees their first backend connection (ServerPostConnectEvent with no previous server — later server switches are ignored) and is then marked delivered so it is not repeated.
Warnings also expire: /warn <player> [duration] <reason> records the duration given, else the warnings.yml default, else nothing at all — which is how warnings behaved before they could expire, so leaving that file untouched changes nothing on upgrade.
Immunity and limits
Two independent guards run before any punishment is issued, both on the proxy.
Immunity (ImmunityService) decides who may punish whom, from the punishments.immunity.<level> permission nodes. A player's level is the highest <level> they hold, defaulting to 0. A target is protected when their level is above 0 and at least the issuer's — so equal ranks cannot punish each other, ordinary players are always punishable, and the console is never refused. Levels are resolved through LuckPerms (with non-contextual query options, so every server computes the same answer) because the target is usually offline and the proxy can only answer permission questions about connected players. Without LuckPerms installed, immunity is not enforced at all and the plugin says so once at startup. A refusal answers target-exempt.
A bare address ban checks immunity against every account ever seen on that address — otherwise an address ban would be a way around immunity, barring a protected account with a row that never names them.
Duration limits (limits.yml) cap how long a punishment a staff member may issue. They apply to BAN, IPBAN and MUTE only — a kick has no duration, and a warning is a record rather than a restriction. Permanent counts as longer than any ceiling, so an omitted duration cannot sidestep the cap. Over the cap answers duration-too-long with %max%.
Data model
Every punishment — active, expired, or pardoned — is one row in the shared punishments table (velocity/src/main/resources/schema.sql), mapped by PunishmentEntity:
Field | Column | Notes |
|---|---|---|
|
| Primary key. |
|
|
|
|
| Nullable — null on a pure IP ban against a bare address. |
|
| The banned address ( |
|
| Null id = console; name defaults to |
|
| Nullable free text. |
|
| Whether the target has been shown this punishment. Only |
|
| Set on issue; refreshed on reissue. |
|
|
|
|
| Flipped to |
|
| Populated by |
A second table, ip_history, records one row per (player_uuid, ip) pair, upserted by IpHistoryListener on every PostLoginEvent. It backs /ipban when the target isn't currently connected (the ban falls back to their last-known address), the immunity check on a bare-address ban, and the alt detection behind /alts.
Enforcement is evaluated live at query time (active = true AND (expires_at IS NULL OR expires_at > now)) — there's no background sweeper; a pardon or an expiry takes effect on the very next check.
Cross-server sync
Both plugin modules construct MessagingService with a PunishmentsPacketRegistry, which registers two request/response packets:
Packet | Opcode | Purpose |
|---|---|---|
|
| Sent by |
|
| Reply carrying whether the disconnect succeeded. |
PunishmentRepository (the common module, used independently by both modules) additionally registers an exchange on punishments.updates (PunishmentConstants.UPDATES_CHANNEL) that carries a PunishmentIssuedPacket(UUID) — sent after every save()/pardon() — and, on receipt, refreshes that punishment's entry in the local Caffeine cache. This is what makes a pardon issued from the paper GUI take effect immediately at the velocity login gate, and a mute issued on the proxy take effect on the game server, without either side polling the other.
velocity's PunishmentService additionally listens on punishments.global (PunishmentConstants.PUNISHMENTS_CHANNEL) for KickPlayerRequestPackets and answers them via localKick(...).
Where to go next
Configuration —
database.properties,messages.yml,limits.yml,warnings.yml,discord.yml,mute.yml,display.yml.Commands — every proxy punishment command plus the paper staff commands.
Placeholders — the
punishment,altandipnamespaces.Staff menus — the lookup hub, history, alts, IP history and in-force listings.
HTTP API — the read-only master module.
LiteBans import — migrating an existing LiteBans database.