Vonix Server Utilities Vonix Mods v2.2.0 Search GitHub
Vonix Server Utilities/Features/Moderation

Moderation#

VSU ships a punishment system with four action types, a duration parser, a SQLite audit table, and an expiry sweeper. It is designed so that every action is recorded, nothing is deleted, and a server restart does not lose a mute or a ban.

Commands#

Command Usage Node Op
/ban /ban <player> [reason] vsu.mod.ban 3
/tempban /tempban <player> <duration> [reason] vsu.mod.ban 3
/unban /unban <player> vsu.mod.ban 3
/banlist /banlist [page] vsu.mod.ban 3
/mute /mute <player> [reason] vsu.mod.mute 3
/tempmute /tempmute <player> <duration> [reason] vsu.mod.mute 3
/unmute /unmute <player> vsu.mod.mute 3
/kick /kick <player> [reason] vsu.mod.kick 2
/warn /warn <player> <reason> vsu.mod.warn 2
/warnings /warnings <player> [page] vsu.mod.warn 2
/clearwarnings /clearwarnings <player> vsu.mod.warn 2

Player arguments accept online names and names the server has seen before, taken from the profile cache. A name that has never joined is rejected with Unknown player: <name> (never joined this server). Tab completion offers online players plus every name that appears in the punishment history.

/banlist and /warnings paginate ten rows per page.

/warn requires a reason. The other actions accept an optional one, and a missing reason is recorded as null and rendered as (no reason).

Duration syntax#

Used by /tempban and /tempmute.

Units, case-insensitive, no spaces:
  s   second
  m   minute
  h   hour
  d   day
  w   week
  mo  month   (30 days)
  y   year    (365 days)

Permanent literals:
  perm   permanent   never

Composition, largest unit first:
  1d12h      7d6h30m      2w3d

Tab completion suggests 1h, 6h, 1d, 7d, 30d, and perm.

The parser returns milliseconds from now, or a permanent sentinel. Its rejection rules are strict:

Input Result Why
30 Rejected A number with no unit is not a duration
-5h Rejected Negative values are refused outright
1h30 Rejected Trailing token with no unit leaves an unconsumed segment
1x Rejected Unknown unit
0s Rejected Total must be positive
200y Rejected Exceeds the 100-year cap
1d 12h Rejected Spaces are not permitted
perm Permanent Literal, case-insensitive
1d12h 36 hours Units sum

A rejection prints Invalid duration: <input> with examples. Nothing is written to the database.

Months and years are fixed-length approximations — 30 and 365 days — not calendar arithmetic. A one-month ban issued on 31 January expires 30 days later, not on 28 February.

Enforcement#

Bans#

A ban is checked at login, in the loader join handler, by querying punishments for an active BAN row whose expiry is null or in the future. If one exists the player is disconnected with a formatted reason and expiry.

If the target is online when the ban is issued, they receive a tellraw message first and are disconnected one second later, so the reason is readable rather than flashing past in the disconnect screen.

Because the check runs at login, a tempban that expires while the player is offline simply stops applying. No unban is needed.

Mutes#

A mute is enforced on three paths:

  • Chat — the loader chat event is cancelled for a muted player.
  • Command messages — a command event inspects the root literal of the parsed input; if it is one of the chat-style commands and the player is muted, the command is cancelled.
  • Feedback — the player receives a message with the remaining time and the reason on every blocked attempt.

The policy is enforced before the database write. A mute takes effect the instant the command runs, even if the asynchronous insert is still queued. If the insert then fails, the optimistic state is rolled back and operators see a failure message stating that no mute was applied.

The in-memory mute set is the authority for enforcement. It is hydrated from the database at server start. Until that hydration completes, the mute check fails closed — a brief window at boot where chat is rejected for everyone rather than risk an outage silently unmuting the server. Hydration runs during the server-started phase and normally finishes in milliseconds.

Kicks#

A kick writes a KICK row and disconnects the target if online. It has no expiry and no lasting effect: a kicked player can reconnect immediately.

Warnings#

A warning writes a WARN row and messages the target with their active warning count. Warnings never expire on their own; /clearwarnings deactivates them.

Expiry sweeper#

A single-threaded scheduler named VonixSU-ModExpirySweeper runs every 60 seconds, starting at server-started and stopping at server-stopping. Each tick queues work on the database executor that:

  1. Snapshots rows where active=1 and expires_at <= now.
  2. Marks them active=0.
  3. For each expired mute, re-checks whether another active mute remains and reconciles the in-memory state.
  4. Messages the affected player ("Your mute has expired.") if they are online and no other mute remains.
  5. Broadcasts a one-line notice to operators.

The sweeper is why /banlist stops showing a tempban shortly after it expires, and why a muted player regains chat without an admin action. It is also why the worst case for expiry is about a minute late: a row that expires one second after a sweep is cleared on the next one.

Enforcement does not wait for the sweeper. A chat attempt by a player whose mute row has expired but not been swept is still refused until the sweep runs, because the cached set is only reconciled by the sweeper or by an unban. The 60-second window is the bound on that behaviour.

Revocation#

/unban and /unmute revoke, they do not delete:

UPDATE punishments SET active=0, revoked_by=?, revoked_at=?
 WHERE id = (SELECT id FROM punishments
              WHERE target_uuid=? AND type=? AND active=1
              ORDER BY issued_at DESC LIMIT 1)

Consequences:

  • The historical row survives with its original reason, issuer, and time.
  • A later punishment of the same type starts a fresh row; history accumulates.
  • /unmute revokes only the newest active mute. If a player somehow has two active mutes, the second stays in force and both the operator and the player are told so.

/clearwarnings is the bulk equivalent for warnings: it sets active=0 on every active WARN row for the target and reports how many were cleared. The rows are retained.

LP integration#

There is one bypass node: vsu.bypass.mute. It is read at enforcement time, so a holder is treated as unmuted while the node is set, and the underlying row stays in place. Revoking the node re-enforces the mute on the next message. See Permissions for the grant pattern and the warning about wildcards.

Mute enforcement cannot be bypassed by granting the messaging node. A muted player with vsu.command.message is still blocked, because the block happens in the chat and command hooks rather than in the Brigadier predicate.

Audit queries#

All four types share the punishments table; the schema is on Database & storage. Timestamps are epoch milliseconds, so divide by 1000 for SQLite's datetime.

Active bans, newest first:

SELECT id, target_name, issuer_name, reason,
       datetime(issued_at/1000, 'unixepoch')  AS issued,
       datetime(expires_at/1000, 'unixepoch') AS expires
  FROM punishments
 WHERE type='BAN' AND active=1
 ORDER BY issued_at DESC;

Full history for one player:

SELECT id, type, reason, issuer_name,
       datetime(issued_at/1000, 'unixepoch') AS issued,
       CASE WHEN expires_at IS NULL THEN 'perm'
            ELSE datetime(expires_at/1000, 'unixepoch') END AS expires,
       active, revoked_by,
       CASE WHEN revoked_at IS NULL THEN ''
            ELSE datetime(revoked_at/1000, 'unixepoch') END AS revoked
  FROM punishments
 WHERE target_name = 'Steve'
 ORDER BY issued_at DESC;

Everything one moderator issued in the last 30 days:

SELECT type, target_name, reason,
       datetime(issued_at/1000, 'unixepoch') AS issued
  FROM punishments
 WHERE issuer_name = 'AdminAlex'
   AND issued_at > (strftime('%s','now') - 30*86400) * 1000
 ORDER BY issued_at DESC;

Every revocation, newest first:

SELECT id, type, target_name, revoked_by,
       datetime(revoked_at/1000, 'unixepoch') AS revoked
  FROM punishments
 WHERE active = 0 AND revoked_by IS NOT NULL
 ORDER BY revoked_at DESC;

Rows that should have been swept but were not, which points at a stopped sweeper:

SELECT id, type, target_name,
       datetime(expires_at/1000, 'unixepoch') AS expired_at
  FROM punishments
 WHERE active = 1 AND expires_at IS NOT NULL AND expires_at <= (strftime('%s','now')*1000);

Export one player's record:

sqlite3 -header -csv config/vonix_server_utilities/data.db \
  "SELECT * FROM punishments WHERE target_name='Steve' ORDER BY issued_at;" \
  > steve-record.csv

For manual correction of a row the commands cannot reach:

UPDATE punishments
   SET active = 0, revoked_by = 'CONSOLE', revoked_at = strftime('%s','now') * 1000
 WHERE id = 87;

Restart afterwards so the mute cache is rebuilt; /vonixsu reload does not re-read the database.

Escalation template#

This is a template, not mod behaviour

Nothing below is enforced or automated by VSU. It is a starting policy you can adopt or ignore. The mod records actions and enforces their durations; it has no concept of an escalation ladder.

Step Action When
1 /warn First minor offence: mild spam, off-topic, mild language
2 /warn Second minor offence within 30 days
3 /tempmute 1h Third warning, or first chat-targeted offence
4 /tempmute 1d Repeat chat offence within 7 days of step 3
5 /tempban 1d First serious offence: harassment, slur, griefing
6 /tempban 7d Repeat serious offence within 30 days of step 5
7 /tempban 30d Repeat serious offence within 90 days of step 6
8 /ban Repeated bans, doxxing, cheating, evasion

Suggested reset rule: ignore warnings older than 90 days when deciding a step. /warnings <player> shows the history you need, though it does not compute ages for you.

Operational notes#

  • Do not bulk-revoke from chat. Use SQL for a mass correction, then restart.
  • A ban does not remove the player from the whitelist or any other list; it is a separate check.
  • Console-issued punishments record issuer_uuid as null and issuer_name as CONSOLE.
  • /kick writes a row. Kicks are part of the audit trail even though they carry no lasting restriction.
  • Timestamps use the server's local zone when printed in game, and UTC when formatted by SQLite's unixepoch modifier. Expect an offset between /banlist output and a raw query if your server is not on UTC.