Imported from ganyuke/peoplehunt-java (
AGENTS.md). Install upstream withnpx skills add ganyuke/peoplehunt-java. Copyright stays with the author.
AGENTS.md — PeopleHunt
Guidelines for AI agents (and humans) working in this codebase.
Project context
PeopleHunt is a Paper plugin (Java 21, Minecraft 1.21+) for running Manhunt-style matches with a built-in after-action report (AAR) system. The AAR records per-tick path samples, discrete events, and inventory state to a per-match SQLite file, then serves a self-contained Preact viewer from an embedded HTTP server.
This is a pre-release plugin. Backward compatibility with existing .db files is not a goal. Schema version bumps without migration are acceptable.
Architecture
Layer boundaries
| Layer | Location | Responsibility |
|---|---|---|
| Game logic | game/match/, listener/match/, listener/report/ |
Match lifecycle, Bukkit event handling, state transitions and AAR capture |
| Recording | report/recording/ |
Translate Bukkit domain objects into ReportModels records and enqueue them |
| Persistence | report/sqlite/ |
SQLite schema, reader, writer, assembler |
| Serving | report/web/, report/persistence/ |
Snapshot assembly, inline HTML generation, embedded HTTP server |
| Viewer | src/main/resources/web/ |
Preact/htm frontend; built by Gradle into viewer.inline.html |
For a full list of tracked events, capture sites, and log rules, see docs/EVENT_TRACKING.md.
Presentation logic belongs in the viewer, not the Java backend. The backend's job is to record facts faithfully (raw IDs, raw enum names, numeric values). Pretty-printing, color assignment, icon resolution, and display formatting happen in viewer-app.js. PrettyNames and Text are the only sanctioned exceptions — they exist to produce human-readable labels for in-game chat messages and plugin log output, not for report payloads.
Key patterns
ReportModelscontains all data transfer records as Javarecordtypes. One file, no logic.ReportEventKindandTimelineRecordKindare enums with awireValue()/fromWire()pattern. Every kind stored in SQLite or a JSON payload must have a wire value. Add new kinds here; do not use raw strings for event/timeline kinds anywhere in production code.- Recorders (
recording/) are statelessfinalclasses with a singlerecord(...)method. They do not ownMatchSessionor scheduler references. MatchSessionis the in-memory runtime state bag for one active match. State that must survive across multiple tick callbacks lives here asMap<UUID, …>fields.MatchTickService.detectStateTransitionsis where tick-driven "last sample vs current sample" comparisons happen (game mode changes, world changes, environmental status). Follow the existing pattern: store previous value viaMap.put, compare, emit a timeline record if changed.SnapshotUtilis the canonical place for reading Bukkit player state into report model types. Keep Bukkit API calls out of recorders.primeObservedStateseeds last-sample maps when a player joins a primed or active match, preventing spurious first-tick transition events. Any newlastSample*map inMatchSessionmust be primed here.
Code quality
No magic strings or numbers
Hoist repeated literals as named constants. This applies to:
- SQL column names referenced in both
ReportSqliteWriterandReportSqliteReader - Wire values for event kinds, timeline kinds, and JSON field names
- Numeric thresholds (tick counts, TTL durations, clamp bounds)
// Bad
session.lastSampleGameModes.put(uuid, "SURVIVAL");
// Good
private static final String GAME_MODE_SURVIVAL = "SURVIVAL";
For SQL, declare column name constants at the top of the schema class, or in a package-private constants class, and reference them in both the CREATE TABLE statement and any prepared statement strings.
Java 21 idioms
- Prefer
recordtypes for immutable data. AllReportModelsentries are records. - Use
sealedinterfaces orswitchexpressions with pattern matching where a fixed set of variants needs exhaustive handling. - Use
varfor local variables where the type is obvious from the right-hand side; avoid it where it obscures intent. - Use
List.of(),Map.of(),Set.of()for small literal collections. - Use
Optionalfor return values that may legitimately be absent (not for parameters or fields). - Prefer
instanceofpattern matching over explicit casts.
Modular code, no god classes
- Each class should handle a single responsibility. If
MatchTickServiceor a class inlistener.matchorlistener.reportis handling three unrelated concerns, extract the extra responsibilities into new classes. - Add new recorder logic as a
XyzRecorderinreport/recording/. - Add new Bukkit event listener logic as a new class in
listener.matchfor gameplay state orlistener.reportfor after-action report capture. - Place utility methods that don't belong to any layer in
util/.
Up-to-date APIs, always
- Avoid deprecated APIs, especially Paper/Bukkit APIs marked for removal.
- Use experimental APIs only as replacements for deprecated APIs or when no stable Paper alternative exists.
- Keep experimental API usage narrow, and add a brief comment explaining why it is necessary and what deprecated or missing stable API it replaces.
Testing
Tests live in src/test/ and use JUnit 5. Bukkit cannot be instantiated in unit tests — anything that touches Player, World, or Bukkit.* must be tested via integration or manually.
- SQLite round-trip tests are the primary coverage mechanism for recording + persistence changes. When adding a new field to
PathPointor a newReportEventKind, add or extend a test inreport/sqlite/. - Use
@TempDirfor per-test database files. - Test class names follow
<Subject>Testconvention. Package mirrors the production package.
Do not run Gradle builds or tests as root.
Some tests intentionally use filesystem permissions, such as making SQLite files read-only, to simulate write failures. Running as root can bypass those permissions and produce misleading test failures. Use a normal, non-root user for ./gradlew build, ./gradlew test, and related verification commands.
Run tests with:
./gradlew test
Build
./gradlew shadowJar # build distributable plugin JAR
./gradlew test # run unit tests
./gradlew runServer # launch a local Paper server with the plugin
./gradlew downloadOfflineDependencies # warm all caches for offline use (run once with network)
./gradlew build --offline # build with no network access (requires prior warm-up)
Completely offline builds
To enable a fully offline build (./gradlew build --offline), you must first run ./gradlew downloadOfflineDependencies while online. This task:
- Pre-fetches Java dependencies (sqlite-jdbc, paper-api, JUnit, etc.) into
~/.gradle/caches/ - Downloads and caches frontend vendor assets (Preact/htm JS and licenses) into
.gradle/peoplehunt/cache/frontend/<name>/<version>/
The syncViewerFrontendAssets task fetches and caches the required frontend JS on first build. Use -PrefreshViewerFrontend to force a fresh download (requires network access); this flag has no effect in offline mode.
If you attempt a build with --offline but the frontend cache is missing, the build will fail immediately and instruct you to run downloadOfflineDependencies.
The viewer source files (viewer-app.js, viewer.css, viewer.template.html) are not bundled directly; instead, the buildInlineViewer task inlines them into viewer.inline.html at build time. Changes to viewer source require a rebuild for updates to be visible on the server.
Completely offline runtime
Mojang assets (en_us.json and item/block/effect textures) are not fetched during the build process. Instead, MojangAssetService downloads these on the first server start (if the cache is empty) and saves them in plugins/PeopleHunt/cache/. After this initial run, all assets are loaded from the cache and no additional downloads occur—even when the server is started with no network access.
Viewer icon overrides are configured with reporting.web.icon-pack-url and reporting.web.icon-pack-sha256. The URL must point to an HTTPS zip containing root index.json plus PNG files referenced by that index, for example index.json and icons/shield.png. IconPackService downloads this zip on the first server start when the cache is cold, verifies the whole-zip SHA-256 when configured, and caches data URIs in plugins/PeopleHunt/cache/icon-pack/icon-pack.json. After that cache is warm and the configured URL/checksum still match, the viewer loads these overrides from disk and does not need network access.
To ensure a server can run fully offline (with all in-game text and icons available), start the server with an internet connection at least once so the Mojang asset cache and any configured icon-pack cache are populated. If reporting.web.icon-pack-url is blank, the icon pack layer is disabled and no icon-pack network or cache read is attempted.
Commit messages
Format:
<type>(<scope>): <short description>
- Key change or design decision
- Another key change
- Why a non-obvious choice was made, if relevant
Co-authored-by: <model> <company email>
Subject line follows Conventional Commits. Common types: feat, fix, refactor, chore, test, docs. Scope is optional but encouraged — use the primary package or class area (e.g. feat(recording), fix(sqlite), refactor(viewer)).
Rules:
- Body bullets are terse. Omit what is obvious from the diff.
- Note design decisions where they aren't self-evident — e.g. why an approach was chosen over an alternative, or why a simpler path was ruled out.
- Always include the
Co-authored-bytrailer when an AI agent authored or substantially revised the commit. Example:Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>.