A roleplay judicial system for Paper, built around the lawyer rather than the police officer.
Hello everyone! I'm Cap. I'm an average tech bro like many others you might found on the internet lol. I wanted to use the introduction just to explain why this plugin exist in the first place. I really love watching legal drama shows. They are not a real representation of Law, but they are really entertaining. And come on, who wouldn't want to become a corrupt but colorful lawyer like Saul Goodman? Or maybe a top player like Harvey Specter. Anyway, I had the chance to build this project for the server I hosted with my friends. With the first tests of this plugin I really had fun, I want to share it with the internet because I hope y'all will have fun with your friends too! And every justice plugin is outdated lmfao.
Anyways, don't drink potions and fight, but if you do, call Cap!
- What it is
- Architecture
- Building and testing
- Configuration
- Permissions
- Integrations
- Project layout
- Known gaps
- License
A court. Players apply to the bar, are instructed as counsel, bring criminal charges and civil claims, file and object to evidence, settle out of court, sit as judges, hand down verdicts, and serve time.
It is deliberately not an arrest plugin. There are no handcuffs, no chases and no wanted level. Custody is the outcome of a judgment, not the start of the story, and every mechanic in here exists to make the part between the accusation and the sentence worth playing.
Everything is reachable two ways: by typing a command, or by clicking through /bcc menu. Those are
the same code path — see The menu for why that matters more than it sounds.
In numbers: 423 source files and ~57,000 lines of main code, 262 test files and ~56,000 lines of test code, 26 services, 28 database tables across 10 migrations, 88 permission nodes, 14 window types, 86 command leaves.
┌─────────────────────────────────────────┐
Bukkit ───────► │ bootstrap · listener · platform.paper │ adapters
└──────────────────┬──────────────────────┘
│
┌──────────────────▼──────────────────────┐
│ command · gui │ presentation
└──────────────────┬──────────────────────┘
│
┌──────────────────▼──────────────────────┐
│ service · storage │ orchestration
└──────────────────┬──────────────────────┘
│
┌──────────────────▼──────────────────────┐
│ domain.model · domain.rules │ decisions
└─────────────────────────────────────────┘
Dependencies point downward only. The consequence that matters in practice:
| Package | Knows about Bukkit? | Testable without a server? |
|---|---|---|
domain.model, domain.rules |
no | yes |
service, storage |
no | yes |
gui (view models) |
no | yes |
gui (*Gui classes) |
yes, minimally | with MockBukkit |
command |
yes | yes, via a bare CommandDispatcher |
platform.paper, listener, bootstrap |
yes | with MockBukkit |
domain.rules holds the decisions that have nothing to do with Minecraft and everything to do with
being a court: CaseStateMachine, PenaltyValidator, AppealPolicy, LimitationPolicy,
DisciplinePolicy, EscrowStateMachine, ReputationCalculator, JudicialFee, WorkRemission,
VerdictSuggestionEngine, FloorControl. Every one is a pure function over immutable records, and
every one is exercised without a server anywhere near it.
Result<T, E> — a sealed interface with Ok and Err — is the return type of anything that can
fail for an ordinary reason: insufficient funds, a case in the wrong state, a lawyer who is not
licensed, a courtroom that is busy.
public CompletableFuture<Result<CourtCase, CaseFailure>> openPenalCase(...)Exceptions are reserved for genuine faults. Two things follow, and both are the point:
- A caller cannot forget to handle the failure — the type will not let them.
- Every failure branch is reachable from a plain unit test, which is what keeps the branch coverage figure honest rather than decorative.
Each failure enum maps to a message key in exactly one place (FailureMessages), so a new failure
mode is a compile error until somebody writes the sentence a player will read.
Everything the plugin needs from the server goes through a narrow port in platform:
TeleportPort, GameModePort, BlockRestorePort, WorkToolPort, CommandDispatchPort, plus
PlayerDirectory, Messenger, PluginLog and EventPublisher.
The Bukkit implementations live in platform.paper, and that package is the only one excluded from
the coverage denominator. The exclusion is granted on one condition, stated in build.gradle.kts
and enforced by review: every class in it is a branchless delegating adapter. No if, no switch,
no loop, no ternary, no catch. The moment one needs a branch, the branch is extracted into a
testable collaborator and the adapter stays branchless.
That is why PlayerLookup and LocationCodec sit in platform and not platform.paper: both
answer a question that can genuinely fail — is this player online, is that world loaded — and both
answer it with an empty Optional so the adapters above them never have to test for null.
// PaperTeleportAdapter, in full. Two things can go wrong and neither is a branch here.
return players.online(playerId)
.flatMap(player -> locations.toBukkit(destination).map(player::teleportAsync))
.orElseGet(() -> CompletableFuture.completedFuture(Boolean.FALSE));There is no BukkitRunnable, no BukkitScheduler and no Bukkit.getScheduler() anywhere in the
codebase. Every thread transition goes through PlatformScheduler:
| Executor | For |
|---|---|
async() |
work that never touches the Bukkit API |
global() |
server-wide state: broadcasts, the courtroom registry, configuration |
forEntity(UUID) |
anything reading or writing a specific player |
forLocation(PlatformLocation) |
anything touching specific blocks |
Choosing between them is a correctness question, not a performance one — which is what lets the
same code run unmodified on Paper and on Folia, where a single main thread does not exist.
plugin.yml declares folia-supported: true.
Database work does not go on async(); it goes on the database's own executor, which serialises
access to the single SQLite connection.
The one place that genuinely crosses threads is chat. AsyncChatEvent fires on a netty thread and
must be decided before the handler returns, so blocking on a scheduler round trip is not an option.
Both chat handlers are built around that: they read only lock-free published snapshots
(FloorControlService.standingOf, PendingPrompts.isWaiting), decide, and hand the consequence to
the right executor afterwards.
SQLite through a single connection on its own executor. Schema version 10, reached through ten
forward migrations run by MigrationRunner at startup:
| V1 initial schema | V2 judge roll | V3 acting in person | V4 lawyer discipline | V5 gamemode hold |
| V6 settlement | V7 prison labour | V8 evidence items | V9 fees | V10 settlement sides |
28 tables. DAOs are plain classes taking a Connection; services own the transaction boundary, so
"which statements are atomic together" is a decision made where the business rule lives rather than
in the persistence layer.
One root — /bcc, with /bettercallcap and /court as aliases — assembled in BccRootCommand from
twelve branch classes and registered through Paper's Brigadier lifecycle API. There is no commands:
block in plugin.yml.
Three things worth knowing:
- Permissions are Brigadier
requirespredicates. Nothing re-checks a node afterwards, and nothing lists one twice. A branch a sender may not use is not merely refused, it is not sent to their client at all. - Bad input is reported, not thrown. Custom argument types (
CaseIdArgument,DurationArgument,PositiveAmountArgument) return aParsed<T>carrying either the value or the text that was not one, so the command answers"142x is not a case number"rather than a caret under a red line. A bare number is refused as a duration on purpose:30means half an hour on one server and half a minute on the next, and guessing wrong hands somebody a sentence thirty times too long. - Half-typed commands answer with their own shape.
CommandUsagegives every branch node an executor that prints the usage line, because Brigadier's "Unknown or incomplete command" cannot say which word is missing.
A hand-rolled MVVM layer in gui, with no third-party inventory library.
*ViewModel ──► List<GuiEntry> ──► BccGui ──► Inventory
(no Bukkit) (no Bukkit) (thin)
A view model is an immutable record that answers size(), title(), entries() and
choiceAt(slot). It holds no Bukkit type and does no work, so "did the escrow screen show the rows
needing review, in the right slots, with the amounts on them" is a question a plain unit test can
ask. The *Gui subclass is typically fifty lines of wiring; what a click means belongs to the
command layer, which is the only place holding a case register and a sender.
Two rules earn their keep:
- Every click in a plugin window is cancelled by
GuiListenerbefore it is routed. Screens are recognised by theirInventoryHolder, never by their title. The single exception is the settlement deposit window, which has to accept items, and it has to say so by overridingacceptsItems()— a decision that must be written down rather than fallen into. - Text is named, never written.
GuiTextcarries a message key plus placeholders, including placeholders whose values are themselves message keys. A case state or a seat has to be translated before it can be substituted into a line that also has to be translated; without that nesting the inner word reaches the player asAWAITING_VERDICT.
/bcc menu is the whole plugin as a window: 77 actions behind ten doors, plus four shortcuts to
screens the court can show without being asked anything first.
It used to be a signpost — every button dropped a command into the chat box for the player to finish — on the argument that a chest has no text field. Half of that was right. A window cannot take a sentence; a menu can ask for one. So it does, one question at a time.
The rule the whole design rests on: a button runs a command; it does not do a job.
MenuCatalogue ──► CommandForm ──► CommandLine.render() ──► "bcc sue Bob 500 late rent"
│ │
└──► NodePaths.canUse() ◄── the same ▼
(is this reachable?) node names Bukkit.dispatchCommand
Every action is a line assembled by CommandLine and dispatched exactly as if it had been typed.
Nothing in the menu calls a service to change anything, so nothing in the menu can disagree with the
command it claims to be: the guards, the validation, the failure messages and the audit trail are not
copies, they are the same ones.
Bukkit.dispatchCommand, deliberately, and not Player#performCommand — the latter fires
PlayerCommandPreprocessEvent, which would let another plugin rewrite one path and not the other.
Answers are collected in whatever way suits the question. A case, an exhibit, a cell or an advocate comes from a picker built from the same source of truth the tab-completion uses, so a player never has to know an exhibit's number. A fine or a term is counted to on a stepper. The grounds — the one thing that genuinely needs a keyboard — close the window and are asked for in chat, with a cancel word and a two-minute expiry.
Two tests hold the bargain, and both run against the real command tree:
MenuCatalogueTestwalks every one of the 77 entries: the path exists, it ends somewhere executable, and each argument is named exactly what the tree names it.CommandLineParsesTestfills every action with a representative value, renders the line, and hands it to a realCommandDispatcher— asserting not only that it parses, but that it reaches the node it claims, so an entry that stopped one word short cannot pass.
The price is that a button cannot exist for a command that does not. That is a real constraint, and it is worth paying: the alternative is two implementations of a court, free to drift apart.
Every payment goes through EscrowService, which is a state machine with a startup reconciliation
pass:
RESERVED ──► HELD ──┬──► RELEASING ──► RELEASED
└──► REFUNDING ──► REFUNDED
│
└──► NEEDS_REVIEW (a human has to look)
ABORTED
A crash mid-transfer leaves a row an administrator can see, never money that has quietly vanished. Reconciliation runs before anything else can reach the plugin and reports what it found in the startup log. Judicial fees are paid once per case, enforced at the storage layer by one row keyed on the case, written before the money moves — so a provider that refuses leaves a claim somebody can chase rather than a judge who might be paid twice.
Every player-facing string is a key in MessageKeys resolved against lang/en_US.yml and
lang/it_IT.yml, rendered with MiniMessage. Adding a language is copying a file.
Three tests guard the bundles, and they exist because this is exactly the sort of thing that rots:
- every constant in
MessageKeysis answered by every shipped language; - no shipped language leaves a message empty;
- the two languages use the same placeholders for the same key — the failure that otherwise
reaches a player as a stray
<amount>.
Values supplied at runtime are inserted literally and never parsed as markup, so a player cannot smuggle MiniMessage into a case file through the grounds they typed.
JsonlAuditLog appends one JSON object per line for every consequential act: cases opened, verdicts
delivered, money moved, licences granted and struck. Append-only, machine-readable, and independent
of the database, so it survives the thing it is meant to explain.
BetterCallCapApi plus eight Bukkit events, two of them cancellable:
| Event | Cancellable |
|---|---|
PreCaseOpenEvent, PrePlayerJailEvent |
✅ |
CaseOpenedEvent, CaseStateChangedEvent, VerdictDeliveredEvent |
— |
LawyerLicensedEvent, LawyerDisbarredEvent, PlayerReleasedEvent |
— |
Domain events are translated into Bukkit events by DomainEventTranslator, so services never learn
that Bukkit exists.
./gradlew build # → build/libs/BetterCallCap-1.0.0.jar
./gradlew check # tests + coverage gate + static analysis + format check
./gradlew test # JUnit 6 + MockBukkit + Mockito
./gradlew spotlessApply # reformatJava 25 toolchain. The jar is not shaded and has no dependencies of its own: Paper already bundles the SQLite driver, Adventure and MiniMessage.
check fails below 95% line and 95% branch, measured by JaCoCo over everything except
platform.paper (see the branchless promise).
Currently 98.2% line, 95.1% branch, across 3,584 tests.
The suite is structured to make that reachable honestly rather than by chasing the number:
CommandTestSupportregisters the real command tree in a bareCommandDispatcherand drives it with the string a player would type — which is the only way to reach argument parsing, permission predicates and suggestion providers, none of which live in a method a test could call.- Fakes over mocks where the question is "where did everybody end up":
FakeTeleportkeeps a little world of positions, so a test asserts "the defendant is back in the mine" rather than "teleport was called twice and I have inferred what that means". - MockBukkit only where a real Bukkit object is unavoidable.
A known blind spot, stated plainly. Tests that substitute a port do not run the listeners a real
server would. That gap is not theoretical: it hid a bug in which CourtSessionManager sent everybody
home before withdrawing the courtroom's containment, so the plugin's own
CourtroomContainmentListener cancelled every one of those teleports. Both individually correct,
both individually well tested. Interaction bugs of that shape need a server, or an integration test
heavy enough to carry an event bus.
ErrorProne and NullAway run as compile errors, not warnings, with JSpecify mode on across
com.bettercallcap. Spotless enforces palantir-java-format. -Pbccap.errorprone=false exists as an
escape hatch for a broken toolchain — not for silencing a finding.
config.yml in fifteen sections:
| Section | Controls |
|---|---|
general |
locale, database file, debug |
economy |
licence fee, currency formatting, whether an economy is required |
court |
hearing length, floor radius, counsel per side, containment, adventure mode |
jail |
cells, restrictions, containment, and the prison-work system |
appeal |
window, fee, whether the fee is a percentage |
limitation |
statute of limitations and its sweep |
lawyer |
daily billing and the discipline system |
discord |
webhooks for case openings, verdicts and disbarments |
suggestion |
how much reputation colours the bench's advisory reading |
reputation |
smoothing and confidence scaling |
bail |
whether the rest of a sentence can be bought, and the rails around the bench |
recidivism |
how much previous convictions raise the least a court may impose |
charges |
the criminal code: display, fine and jail ranges, optional permission |
specializations |
practice areas and their licence fees |
bench |
judicial fees per case, per exhibit and per objection |
/bcc reload re-reads the file, the catalogues and the language files. A handful of keys are
restart-only and say so in their comments: general.database-file, economy.require-economy and all
of discord.
The criminal code is entirely data. A charge is a key, a display key, a fine range, a jail range and optionally a permission node reserving it — so a server's offences are a config decision, not a code one.
92 nodes in two groups:
bettercallcap.player.*— defaulttrue. Everything an ordinary citizen may do: apply to the bar, bring a claim, file evidence, instruct counsel, read their own record.bettercallcap.staff.*— operators only. Presiding, ruling on evidence, managing courtrooms and cells, the case register and the strongroom.
Individual charges may be reserved behind their own node in config.yml, in which case they vanish
from both tab-completion and the menu for anybody without it.
| Vault | economy | optional — the plugin runs without one |
| PlaceholderAPI | 8 placeholders under bettercallcap |
optional |
| Discord | webhooks, no library | optional |
Placeholders: reputation, winrate, active_cases, is_lawyer, specialization, jailed,
jail_remaining, convictions.
src/main/java/com/bettercallcap/
├── api/ developer API and the Bukkit events it publishes
├── audit/ append-only JSONL audit log
├── bootstrap/ plugin entry point, wiring, startup and shutdown order
├── command/ the /bcc tree
│ ├── argument/ custom argument types that report rather than throw
│ ├── menu/ the catalogue behind /bcc menu, and how a button becomes a command
│ └── suggestion/ tab-completion providers
├── config/ config.yml parsing, the criminal code, practice areas
├── domain/
│ ├── model/ immutable records: cases, evidence, sentences, escrows
│ ├── rules/ the decisions, as pure functions
│ └── event/ domain events, free of Bukkit
├── gui/ view models (Bukkit-free) and the thin screens over them
├── i18n/ message keys, bundles, MiniMessage rendering
├── integration/ economy, Discord, PlaceholderAPI
├── listener/ Bukkit event adapters, each delegating to a testable policy
├── notify/ court notices and broadcasts
├── platform/ ports; `platform.paper` holds the branchless adapters
├── service/ 26 services: the orchestration layer
├── storage/ SQLite, DAOs, migrations
└── util/ durations, money formatting, pagination
Stated rather than hidden, because a README that only lists what works is a README nobody can plan against.
- Courtrooms and cells cannot be enabled, disabled or re-radiused after creation.
CourtroomService.clearSeatandsetCentreexist and are reachable from nothing; six language keys are already written and waiting for the commands that would use them. - The menu's click paths are unverified on a live server. The catalogue, the assembled command
lines and every view model are covered by tests, and the window opens cleanly in production — but
no automated test can generate an
InventoryClickEvent, so pickers, the stepper and the chat prompt have not been exercised end to end by anything but a human. - Not enforced: a maximum number of exhibits per case, a maximum length for a stated reason.
/bcc record,/bcc bailand the expungement clause are not on the/bcc menuwindow yet. They work when typed and their permissions behave; they are simply absent from the point-and-click catalogue.error.unknown-subcommandis unreachable by design. Catching an unknown subcommand would need a greedy catch-all on/bccthat would kill the client-side error highlighting for the whole namespace. (error.no-permissionused to be unreachable for the matching reason — Brigadier hides a node rather than refusing it — and now has exactly one caller: reading somebody's criminal record is earned either by a node or by standing in the case being heard, and a rule with two ways in cannot be enforced by hiding the node.)- Names must be single words. Courtrooms and cells are read with
StringArgumentType.word(), so a room calledGreat Hallcannot be reached by any command. The menu refuses such a name when it is typed and leaves pre-existing ones out of its pickers rather than offering a click that fails.
BetterCallCap is free software, licensed under the GNU General Public License v3.0. The full text
is in LICENSE.
In short: you may use it, study it, change it and share it. If you distribute a modified version — including running a fork and handing the jar to somebody else — you have to release your changes under the same licence and make the source available. That is the point of choosing copyleft rather than a permissive licence: the court stays open.
Copyright (C) 2026 Cap
This program is free software: you can redistribute it and/or modify it under the terms of
the GNU General Public License as published by the Free Software Foundation, either version 3
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program.
If not, see <https://www.gnu.org/licenses/>.