Astral Realms Documentation Help

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 on astralcore) 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

BAN, IPBAN

BanListener on PreLoginEvent

velocity

MUTE

MuteListener on AsyncChatEvent + PlayerCommandPreprocessEvent

paper

WARN

Delivered to the player (WarningListener), then recorded as shown

velocity

KICK

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

uniqueId

id

Primary key.

type

type

PunishmentType, persisted as its ordinal (INT: 0=BAN, 1=IPBAN, 2=MUTE, 3=KICK, 4=WARN).

playerId/playerName

player_uuid/player_name

Nullable — null on a pure IP ban against a bare address.

ip

ip

The banned address (IPBAN) or the address recorded at punish-time (other types). VARCHAR(45), textual IPv4/IPv6.

operatorId/operatorName

operator_uuid/operator_name

Null id = console; name defaults to "Console".

reason

reason

Nullable free text.

delivered

delivered

Whether the target has been shown this punishment. Only WARN uses it — see Warning delivery.

createdAt

created_at

Set on issue; refreshed on reissue.

expiresAt

expires_at

null or -1 = permanent (isPermanent()); otherwise a millisecond timestamp (isExpired() compares against "now").

active

active

Flipped to false by revoke() /pardon; history rows are never deleted.

removedById/removedByName/removedAt/removedReason

removed_*

Populated by revoke() when pardoned.

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

KickPlayerRequestPacket

0x00

Sent by velocity's PunishmentService#kick when the target isn't connected to the issuing proxy, asking whoever holds that connection to disconnect them.

KickPlayerResponsePacket

0x01

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, alt and ip namespaces.

  • 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.

Last modified: 25 September 2026