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:
- Snapshots rows where
active=1andexpires_at <= now. - Marks them
active=0. - For each expired mute, re-checks whether another active mute remains and reconciles the in-memory state.
- Messages the affected player ("Your mute has expired.") if they are online and no other mute remains.
- 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.
/unmuterevokes 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_uuidas null andissuer_nameasCONSOLE. /kickwrites 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
unixepochmodifier. Expect an offset between/banlistoutput and a raw query if your server is not on UTC.