Skip to content

Repository files navigation

BetterCallCap

A roleplay judicial system for Paper, built around the lawyer rather than the police officer.

Java Paper Folia Gradle

Tests Line coverage Branch coverage Static analysis Style Languages License


Introduction

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!


Table of contents


What it is

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.


Architecture

The layers, and which way they point

                    ┌─────────────────────────────────────────┐
   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.

Expected failure is a value, not an exception

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:

  1. A caller cannot forget to handle the failure — the type will not let them.
  2. 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.

Platform ports, and the branchless promise

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));

Threading

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.

Storage

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.

Commands

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 requires predicates. 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 a Parsed<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: 30 means 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. CommandUsage gives every branch node an executor that prints the usage line, because Brigadier's "Unknown or incomplete command" cannot say which word is missing.

Windows

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 GuiListener before it is routed. Screens are recognised by their InventoryHolder, never by their title. The single exception is the settlement deposit window, which has to accept items, and it has to say so by overriding acceptsItems() — a decision that must be written down rather than fallen into.
  • Text is named, never written. GuiText carries 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 as AWAITING_VERDICT.

The menu

/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:

  • MenuCatalogueTest walks every one of the 77 entries: the path exists, it ends somewhere executable, and each argument is named exactly what the tree names it.
  • CommandLineParsesTest fills every action with a representative value, renders the line, and hands it to a real CommandDispatcher — 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.

Money and escrow

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.

Internationalisation

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 MessageKeys is 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.

Audit log

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.

Developer API

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.


Building and testing

./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   # reformat

Java 25 toolchain. The jar is not shaded and has no dependencies of its own: Paper already bundles the SQLite driver, Adventure and MiniMessage.

The coverage gate

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:

  • CommandTestSupport registers the real command tree in a bare CommandDispatcher and 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": FakeTeleport keeps 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.

Static analysis

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.


Configuration

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.


Permissions

92 nodes in two groups:

  • bettercallcap.player.* — default true. 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.


Integrations

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.


Project layout

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

Known gaps

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.clearSeat and setCentre exist 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 bail and the expungement clause are not on the /bcc menu window yet. They work when typed and their permissions behave; they are simply absent from the point-and-click catalogue.
  • error.unknown-subcommand is unreachable by design. Catching an unknown subcommand would need a greedy catch-all on /bcc that would kill the client-side error highlighting for the whole namespace. (error.no-permission used 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 called Great Hall cannot 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.

License

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

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages