Compare commits
14 Commits
fix-gradle
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b122200f5d | |||
| 1a879be8b7 | |||
| 81da9ece26 | |||
| 3f49117286 | |||
| 3d6195e15d | |||
| be91726ccb | |||
| 78cd1fbdf0 | |||
| 4bb0046b26 | |||
| c3d76e84f2 | |||
| 03232e90f6 | |||
| 1e19b4cf4d | |||
|
|
fefa4a5ecc | ||
| 6d4cffed16 | |||
| 16be005595 |
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Dev-server config for this project's tools/ scripts.
|
||||||
|
# Plain KEY=value only (no shell expansion). Environment variables take precedence.
|
||||||
|
|
||||||
|
PAPER_VERSION=26.2
|
||||||
|
PAPER_BUILD=129
|
||||||
|
MINECRAFT_SERVER_JAR=paper-26.2-129.jar
|
||||||
|
# Relative paths are resolved from this directory; ~/ is expanded.
|
||||||
|
MINECRAFT_SERVER_PATH=./server
|
||||||
|
MINECRAFT_BACKUP_PATH=./backups
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,3 +1,8 @@
|
|||||||
.gradle/
|
.gradle/
|
||||||
build/
|
build/
|
||||||
target/
|
target/
|
||||||
|
.worktrees/
|
||||||
|
bin/
|
||||||
|
/backups/
|
||||||
|
/server/
|
||||||
|
.env
|
||||||
|
|||||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -3,5 +3,8 @@
|
|||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true
|
||||||
},
|
},
|
||||||
"java.configuration.updateBuildConfiguration": "interactive",
|
"java.configuration.updateBuildConfiguration": "interactive",
|
||||||
"java.compile.nullAnalysis.mode": "automatic"
|
"java.compile.nullAnalysis.mode": "automatic",
|
||||||
|
"chat.tools.terminal.autoApprove": {
|
||||||
|
"printf": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
280
Project.md
Normal file
280
Project.md
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
# ScoreKeeper — Project Reference
|
||||||
|
|
||||||
|
> **For a developer joining the team cold.** Read top-to-bottom in ~8 minutes;
|
||||||
|
> you'll know where to look and what's real vs. planned on day one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What is ScoreKeeper?
|
||||||
|
|
||||||
|
ScoreKeeper is a **Paper Minecraft plugin** (Java 21, Bukkit/Adventure APIs) that manages player
|
||||||
|
score tracking. It exposes five in-game/console commands (`/score-get`, `/score-add`,
|
||||||
|
`/score-subtract`, `/score-reset`, `/score-archive`) for manual score manipulation — no events,
|
||||||
|
no timers, no automation.
|
||||||
|
|
||||||
|
**State of the project:** live in-memory scores work today; the \"high-score table\" advertised
|
||||||
|
in the README name and `/score-archive` description is **not implemented**. Persistence, ranking,
|
||||||
|
and scoring formulas are all **future scope**. See §4 for the gap between *what exists* and
|
||||||
|
*what is planned*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Tech Stack & Build
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
|------|-------|
|
||||||
|
| Language | Java 21 (Gradle auto-downloads toolchain) |
|
||||||
|
| Runtime target | Paper 1.21.7 (Bukkit + Adventure APIs) |
|
||||||
|
| Build tool | Gradle 8.14.3 (`gradlew`) |
|
||||||
|
| Linting | Spotless (`googleJavaFormat()` + license header from `config/license-header.txt`) |
|
||||||
|
| Release | `net.researchgate.release` plugin; tags `v$version`; rejects snapshot deps except paper-api |
|
||||||
|
| External deps | **None at runtime.** `paper-api:1.21.7-R0.1-SNAPSHOT` is `compileOnly` only. |
|
||||||
|
| CI/release entry point | `./gradlew spotlessCheck build` then `./gradlew release` |
|
||||||
|
|
||||||
|
### Build quickly
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew build # compiles, runs tests (none yet)
|
||||||
|
./gradlew spotlessApply # format to project style
|
||||||
|
./gradlew assemble # produces jar in build/libs/
|
||||||
|
```
|
||||||
|
|
||||||
|
Drop the resulting jar onto a Paper server's `plugins/` directory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architecture Overview
|
||||||
|
|
||||||
|
### Directory layout
|
||||||
|
|
||||||
|
```
|
||||||
|
├── src/main/java/com/majinnaibu/minecraft/plugins/scorekeeper/
|
||||||
|
│ ├── ScoreKeeperPlugin.java ← entry point, onEnable/onDisable, score CRUD core
|
||||||
|
│ └── commands/
|
||||||
|
│ ├── ScoreGetCommand.java → /score-get [player]
|
||||||
|
│ ├── ScoreAddCommand.java → /score-add [player] <amount>
|
||||||
|
│ ├── ScoreSubtractCommand.java → /score-subtract [player] <amount>
|
||||||
|
│ ├── ScoreResetCommand.java → /score-reset [player]
|
||||||
|
│ └── ScoreArchiveCommand.java → /score-archive [player] (stub)
|
||||||
|
├── src/main/resources/
|
||||||
|
│ └── plugin.yml ← command manifest + main-class declare
|
||||||
|
├── tools/bash/ & tools/powershell/ ← dev helper scripts
|
||||||
|
├── config/license-header.txt ← Spotless license header
|
||||||
|
├── build.gradle ← Gradle build config (above)
|
||||||
|
├── gradle.properties ← version, group coordinates
|
||||||
|
├── CONTRIBUTING.md ← dev env setup
|
||||||
|
└── README.md ← user-facing command reference + notes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data flow (text diagram)
|
||||||
|
|
||||||
|
```
|
||||||
|
ADMIN / CONSOLE / RCON ──▶ types a /score-* command
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ 5× Score*Command.java│ executors parse args,
|
||||||
|
│ resolves target │ resolvePlayerExact(name) (online-only)
|
||||||
|
└──────────┬───────────┘
|
||||||
|
│ calls
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ ScoreKeeperPlugin │
|
||||||
|
│ │
|
||||||
|
│ addScore / subtractScore │ read-modify-write
|
||||||
|
│ resetScore / setScore │
|
||||||
|
│ getScore (read) │
|
||||||
|
│ archiveScore (STUB — no-op) │
|
||||||
|
└──────────┬───────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
HashMap<UUID, Integer> ← in RAM only
|
||||||
|
_playerScores ← ScoreKeeperPlugin:38
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key architectural facts:**
|
||||||
|
|
||||||
|
- **Single class owns everything.** `ScoreKeeperPlugin` holds the scores map, score CRUD
|
||||||
|
methods, logging helpers (logWarning/logInfo/logError), and the Adventure component builder
|
||||||
|
for chat color. There is no service layer or boundary separation.
|
||||||
|
- **No event listeners.** Zero `@EventHandler`/`Listener` registrations across the codebase.
|
||||||
|
Game events (join, death, kill) do not affect scores. Only manual commands change state.
|
||||||
|
- **No scheduler or tick logic.** No periodic tasks, no countdowns, no automated scoring.
|
||||||
|
- **No permission nodes.** Every command is available to every sender (player, console, RCON).
|
||||||
|
|
||||||
|
### File: ScoreKeeperPlugin.java (`ScoreKeeperPlugin`)
|
||||||
|
|
||||||
|
- `main` class in `plugin.yml` (Bukkit plugin entry point)
|
||||||
|
- `HashMap<UUID, Integer> _playerScores` at line 38 — the sole score store
|
||||||
|
- `onEnable()` (line 52): wires five command executors, logs \"load not implemented\" warning
|
||||||
|
- `onDisable()` (line 46): logs \"save not implemented\" warning — scores lost on shutdown
|
||||||
|
- Score CRUD methods: `addScore`, `subtractScore`, `resetScore`, `setScore`, `getScore`,
|
||||||
|
`archiveScore` (lines 67–91, 76–86, 72–74)
|
||||||
|
- Private helpers: `getPlayerScore(Player)` (get-or-create at 0), `setPlayerScore(Player,int)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Domain Model & Scoring Logic
|
||||||
|
|
||||||
|
### 4.1 What exists today (REAL — in code)
|
||||||
|
|
||||||
|
| Aspect | Detail | Source |
|
||||||
|
|--------|--------|--------|
|
||||||
|
| Score model | Single `int` per player, keyed by UUID | `ScoreKeeperPlugin:38` |
|
||||||
|
| Storage container | `HashMap<UUID, Integer>` on plugin instance | same file |
|
||||||
|
| Default value | `0` — lazy-created on first map access | `getPlayerScore:100-106` |
|
||||||
|
| Live only | **No persistence.** Scores erased on server restart | `onEnable:59`, `onDisable:47` |
|
||||||
|
| Scoring direction | Any integer (negatives allowed, no floor) | add/subtract are raw `+`/`-` |
|
||||||
|
| Recording method | Manual commands only — `/score-add`, `/score-subtract` | command executors |
|
||||||
|
| Auto-scoring | **None** — no events, no timers | proven by grep across src/ |
|
||||||
|
| Permissions | **None declared.** All commands open to all senders | `plugin.yml`, no permission guard in code |
|
||||||
|
|
||||||
|
### 4.2 Open Score Lifecycle (what exists + what is planned)
|
||||||
|
|
||||||
|
```
|
||||||
|
Stage 1. First access — getPlayerScore() auto-creates key at 0 [REAL]
|
||||||
|
2. Admin runs /score-add player N or /score-subtract [REAL]
|
||||||
|
3. Player accumulates points over the session [REAL]
|
||||||
|
4. Read via /score-get (read-only, lazily registers) [REAL]
|
||||||
|
5. Server restart — scores LOST on shutdown [REAL]
|
||||||
|
6. Intended: /score-archive freezes score → table [GAP ✓ not built]
|
||||||
|
7. Intended: high-score table display command [GAP ✓ not built]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 The "high-score table" — status
|
||||||
|
|
||||||
|
| Feature | Status | Details |
|
||||||
|
|---------|--------|---------|
|
||||||
|
| `/score-archive` | **Stub** | Command prints \"archive command unimplemented\"; `archiveScore()` method only logs, never writes a table or resets the player's score |
|
||||||
|
| Persistence (save) | **Not built** | `onDisable()` is a TODO stub — map discarded at shutdown |
|
||||||
|
| Persistence (load) | **Not built** | `onEnable()` is a TODO stub — map always starts empty `{}` |
|
||||||
|
| Sorting / ranking | **Not coded** | No sort, no tie-breaking, no entry cap, no decay logic exists |
|
||||||
|
| Scoring formulas | **Not coded** | Scores are plain integer accumulators (`Σ(adds) − Σ(subtracts)`) |
|
||||||
|
|
||||||
|
**Design decisions to be made (none answered by code today):**
|
||||||
|
|
||||||
|
- Storage format for the table (YAML per Bukkit convention; JSON? SQLite?)
|
||||||
|
- What an entry looks like (name + score + timestamp? name is not stored with score today)
|
||||||
|
- Sort order and tie-breaking strategy
|
||||||
|
- Max entries / leaderboard cap
|
||||||
|
- Whether `/score-archive` also resets the live score (README says it does)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Plugin Integration & Reference Table
|
||||||
|
|
||||||
|
### 5.1 Command → Handler → Effect on score data
|
||||||
|
|
||||||
|
| Trigger | Usage | Handler | Effect |
|
||||||
|
|---------|-------|---------|--------|
|
||||||
|
| `/score-get [player]` | Self or other | `ScoreGetCommand.java:38-84`, delegated to `getScore → getPlayerScore` | **Read-only.** Returns integer. Lazily creates entry at `0` if unseen. |
|
||||||
|
| `/score-add [player] <N>` | Self (omit name) or target others | `ScoreAddCommand.java` | `score += N`. Amount must parse as int. No direction validation (negative N still adds). |
|
||||||
|
| `/score-subtract [player] <N>` | Same | `ScoreSubtractCommand.java` | `score -= N`. No minimum clamping; negatives freely produced. |
|
||||||
|
| `/score-reset [player]` | Same | `ScoreResetCommand.java` | `score = 0`. Key created at `0` if absent. |
|
||||||
|
| `/score-archive [player]` | Same | `ScoreArchiveCommand.java` | **No-op.** Prints \"archive command unimplemented\". Does NOT call the `archiveScore()` method. |
|
||||||
|
|
||||||
|
### 5.2 Shared behavior details
|
||||||
|
|
||||||
|
Every executor follows this pattern:
|
||||||
|
|
||||||
|
1. Parse arguments — if `split.length == 1` and sender is a player, target = self (RCON/console
|
||||||
|
requires an explicit `<playerName>` or prints usage).
|
||||||
|
2. Resolve target player via `server.getPlayerExact(name)` — **exact, case-sensitive, online-only.**
|
||||||
|
3. Call the corresponding `ScoreKeeperPlugin` method.
|
||||||
|
4. Echo color-coded result; return `true`.
|
||||||
|
|
||||||
|
### 5.3 Inter-plugin / public API surface
|
||||||
|
|
||||||
|
`ScoreKeeperPlugin` exposes these public methods that other plugins *could* call if they hold a reference
|
||||||
|
(but there is **no formal service registration**):
|
||||||
|
|
||||||
|
| Method | Visibility | Called by commands? | Notes |
|
||||||
|
|--------|------------|---------------------|-------|
|
||||||
|
| `addScore(Player, int)` | `public` | Yes (`/score-add`) | Read-modify-write on `_playerScores` |
|
||||||
|
| `subtractScore(Player, int)` | `public` | Yes (`/score-subtract`) | Same pattern |
|
||||||
|
| `getScore(Player)` | `public` | Yes (`/score-get`) | Wrapper around `getPlayerScore` |
|
||||||
|
| `setScore(Player, int)` | `public` | **No** | Internal write path only; not hooked to any command |
|
||||||
|
| `resetScore(Player)` | `public` | Yes (`/score-reset`) | Sets to `0` |
|
||||||
|
| `archiveScore(Player)` | `public` | **No** | Only logs a warning; never called by the archive command |
|
||||||
|
|
||||||
|
### 5.4 Concurrency note
|
||||||
|
|
||||||
|
The map is a plain `HashMap`. Safe because Paper dispatches commands on the server's single main
|
||||||
|
thread — but it is **not safe for off-thread use**. Any future event-driven scoring that runs
|
||||||
|
asynchronously could corrupt state via non-atomic read-modify-write.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Configuration Reference
|
||||||
|
|
||||||
|
| File | Format | Purpose |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `plugin.yml` | YAML (Bukkit manifest) | Declares main class, api-version, five commands + descriptions/usage strings |
|
||||||
|
| `build.gradle` | Gradle Kotlin (Groovy DSL) | Dependencies, task config, release/spotless settings |
|
||||||
|
| `gradle.properties` | Properties | Project version and group coordinates (for Maven publishing) |
|
||||||
|
| **No `config.yml`** | — | ScoreKeeper has **no player-editable configuration**. |
|
||||||
|
| **No `permissions:` block** | — | No permission nodes declared; all commands are open. Plans say \"permissions coming after archive works.\" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Running & Testing Locally
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew assemble # produces ScoreKeeper.jar in build/libs/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run locally (Paper server)
|
||||||
|
|
||||||
|
1. Download Paper 1.21.7 from `https://papermc.io`
|
||||||
|
2. Copy the built jar into `plugins/`
|
||||||
|
3. Start the server, verify onEnable logs:
|
||||||
|
```
|
||||||
|
[ScoreKeeper] ScoreKeeper version X.Y.Z is enabled.
|
||||||
|
[ScoreKeeper] Unable to load scores from file. This feature is not implemented yet.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commands (in-game or via RCON/console)
|
||||||
|
|
||||||
|
| Test scenario | Command | Expected output |
|
||||||
|
|--------------|---------|-----------------|
|
||||||
|
| Check own score | `/score-get` | \"Your score is 0\" (creates entry at 0) |
|
||||||
|
| Add points to self | `/score-add 10` | \"You gained 10 points! Now have 10.\" |
|
||||||
|
| Subtract from self | `/score-subtract 5` | \"You lost 5 points! Now have 5.\" |
|
||||||
|
| Check another player | `/score-get PlayerName` | \"PlayerName's score is N.\" (must be online) |
|
||||||
|
| Reset own score | `/score-reset` | \"Your score has been reset to 0.\" |
|
||||||
|
| Archive (stub) | `/score-archive` | \"archive command unimplemented\" |
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- **No unit/integration tests exist yet.** `./gradlew test` runs an empty suite. Consider
|
||||||
|
adding tests for `ScoreKeeperPlugin`'s private `Player` score state mocking in a future task.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Where to Look First
|
||||||
|
|
||||||
|
| I want to understand… | Go to |
|
||||||
|
|----------------------|-------|
|
||||||
|
| The entire live data model | `ScoreKeeperPlugin:38` — one `HashMap<UUID, Integer>` field |
|
||||||
|
| How scores change (write path) | `addScore(/:67-70)`, `subtractScore(/:88-91)`, `setPlayerScore(/:108-110)` |
|
||||||
|
| How a player is looked up | `getPlayerExact(name)` online only — see any `*Command.java` line ~50 |
|
||||||
|
| First-time-player behavior | `getPlayerScore(/:100-106)` — auto-inserts `0` on first access |
|
||||||
|
| High-score / archive table | **Does not exist.** See `archiveScore(/:72-74)` stub; design decisions in §4.3 |
|
||||||
|
| Persistence (save/load) | `onDisable(/:47)`, `onEnable(/:59)` — both TODO, nothing writes to disk |
|
||||||
|
| What's declared/intended but not wired | `plugin.yml:18-20` (`/score-archive`), `README.md:11` and README notes |
|
||||||
|
| Command implementations | `commands/Score*Command.java` (all 5 handlers) |
|
||||||
|
| Build/runtime config | `build.gradle`, `plugin.yml`, `gradle.properties` |
|
||||||
|
| Dev env / CONTRIBUTING | `CONTRIBUTING.md` |
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: Risk Summary
|
||||||
|
|
||||||
|
| # | Issue | Impact | Section |
|
||||||
|
|---|-------|--------|---------|
|
||||||
|
| 1 | **Data loss on restart** — no save/load implemented | Every server boot wipes all scores (§4.1) | §4.1 |
|
||||||
|
| 2 | **No permissions** — any player can self-add points | Integrity of scoring is unenforceable today (§5.2) | §5.2 |
|
||||||
|
| 3 | **Race condition on add/subtract** — non-atomic read-modify-write on plain `HashMap` | Corrupt state if future event-driven scoring runs async (§5.4) | §5.4 |
|
||||||
|
| 4 | **No `int` overflow safety** — Java wrapping semantics apply | Undetectable score corruption near ±2.1 billion | §4.1 |
|
||||||
|
| 5 | **Name lookups online-only & case-sensitive** | Can't target offline players; \"Alice\" ≠ \"alice\" (§5.2) | §5.2 |
|
||||||
23
ProjectDescription.json
Normal file
23
ProjectDescription.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"id": "scorekeeper",
|
||||||
|
"title": "ScoreKeeper",
|
||||||
|
"description": "Paper Minecraft plugin for tracking and managing player scores.",
|
||||||
|
"docId": "ScoreKeeper",
|
||||||
|
"image": {
|
||||||
|
"url": "https://image.pollinations.ai/prompt/pixel%20art%20Minecraft%20server%20scoreboard%20with%20a%20glowing%20diamond%20and%20number%20counters%2C%20clean%20square%20game%20plugin%20thumbnail%2C%20no%20text",
|
||||||
|
"alt": "Pixel art scoreboard with a diamond for the ScoreKeeper Minecraft plugin",
|
||||||
|
"prompt": "Pixel art Minecraft server scoreboard with a glowing diamond and number counters, clean square game plugin thumbnail, no text",
|
||||||
|
"width": 1024,
|
||||||
|
"height": 1024,
|
||||||
|
"backgroundClassName": "bg-surface"
|
||||||
|
},
|
||||||
|
"url": "/projects/scorekeeper",
|
||||||
|
"tags": [],
|
||||||
|
"source": {
|
||||||
|
"type": "",
|
||||||
|
"url": ""
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"url": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
16
ProjectDescription.md
Normal file
16
ProjectDescription.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# ScoreKeeper
|
||||||
|
|
||||||
|
ScoreKeeper is a Paper Minecraft plugin for tracking player scores during server activities, events, or games. It keeps scores per player in memory and exposes commands for viewing, changing, resetting, and archiving scores.
|
||||||
|
|
||||||
|
## Details
|
||||||
|
|
||||||
|
- Built for Paper API 1.21.7 with Java 21 and Gradle.
|
||||||
|
- Registers `/score-get`, `/score-add`, `/score-subtract`, `/score-reset`, and `/score-archive`.
|
||||||
|
- Commands use the executing player when a player name is omitted.
|
||||||
|
- Scores are currently held in memory and are not persisted when the server stops.
|
||||||
|
- Score archiving is planned, but the current implementation reports that archiving is unavailable.
|
||||||
|
- The project is packaged as a Java plugin with a `plugin.yml` descriptor.
|
||||||
|
|
||||||
|
## Image
|
||||||
|
|
||||||
|
No project image asset is included in the repository. The JSON description includes a prompt-backed image URL for a future project thumbnail.
|
||||||
21
build.gradle
21
build.gradle
@@ -23,10 +23,24 @@ dependencies {
|
|||||||
|
|
||||||
java {
|
java {
|
||||||
toolchain {
|
toolchain {
|
||||||
languageVersion = JavaLanguageVersion.of(21)
|
// Paper 26.1+ requires Java 25.
|
||||||
|
languageVersion = JavaLanguageVersion.of(25)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.named('wrapper') {
|
||||||
|
gradleVersion = '9.8.0'
|
||||||
|
distributionType = Wrapper.DistributionType.BIN
|
||||||
|
distributionBase = Wrapper.PathBase.PROJECT
|
||||||
|
distributionPath = '.gradle/wrapper/dists'
|
||||||
|
archiveBase = Wrapper.PathBase.PROJECT
|
||||||
|
archivePath = '.gradle/wrapper/dists'
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named('updateDaemonJvm') {
|
||||||
|
languageVersion = JavaLanguageVersion.of(25)
|
||||||
|
}
|
||||||
|
|
||||||
processResources {
|
processResources {
|
||||||
inputs.property "version", project.version
|
inputs.property "version", project.version
|
||||||
filesMatching('plugin.yml') {
|
filesMatching('plugin.yml') {
|
||||||
@@ -43,7 +57,8 @@ publishing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
release {
|
release {
|
||||||
buildTasks.addAll(['spotlessCheck', 'build'])
|
// buildTasks.addAll(['spotlessCheck', 'build'])
|
||||||
|
buildTasks = ['spotlessCheck', 'build']
|
||||||
failOnSnapshotDependencies = true
|
failOnSnapshotDependencies = true
|
||||||
ignoredSnapshotDependencies = [
|
ignoredSnapshotDependencies = [
|
||||||
"io.papermc.paper:paper-api"
|
"io.papermc.paper:paper-api"
|
||||||
@@ -60,7 +75,7 @@ repositories {
|
|||||||
|
|
||||||
spotless {
|
spotless {
|
||||||
java {
|
java {
|
||||||
googleJavaFormat()
|
googleJavaFormat('1.27.0')
|
||||||
importOrder 'java', 'javax', 'com', 'net', 'org', ''
|
importOrder 'java', 'javax', 'com', 'net', 'org', ''
|
||||||
licenseHeaderFile 'config/license-header.txt'
|
licenseHeaderFile 'config/license-header.txt'
|
||||||
target 'src/**/*.java'
|
target 'src/**/*.java'
|
||||||
|
|||||||
365
docs/domain-score-tracking.md
Normal file
365
docs/domain-score-tracking.md
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
# ScoreKeeper — Score-Tracking & "High-Score Table" Domain Analysis
|
||||||
|
|
||||||
|
> **Scope of this document:** the domain logic that tracks player scores and (intended) the
|
||||||
|
> high-score table. Written for a new team member who must understand the scoring system
|
||||||
|
> *without opening the source*. Every claim below is traced to a file/line so it can be
|
||||||
|
> re-verified.
|
||||||
|
>
|
||||||
|
> **Bottom line up front (read this first):** ScoreKeeper currently tracks **live scores
|
||||||
|
> only, in memory, for the running server session**. It has **no persistence** and **no
|
||||||
|
> high-score table**. The high-score table is *described as a planned feature* in the
|
||||||
|
> README and is where the `/score-archive` command is *intended* to land scores, but
|
||||||
|
> **that code path is an unimplemented stub today.** There is no sorting, no tie-breaking,
|
||||||
|
> no entry cap, no decay, and no scoring formula/weighting anywhere in the codebase.
|
||||||
|
> This is a greenfield/early-stage domain — the write-up documents the *real* state and
|
||||||
|
> flags the gap, because documenting features that don't exist as if they did would mislead
|
||||||
|
> the next engineer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. How to read the rest of this doc against the task's four questions
|
||||||
|
|
||||||
|
The task asks for (1) the score-entry data model + persistence format, (2) how scores are
|
||||||
|
recorded, (3) the high-score-table algorithm, and (4) scoring formulas/weighting.
|
||||||
|
For each question the answer is: **the mechanism that exists vs. the mechanism the project
|
||||||
|
intends**, so readers never conflate the two.
|
||||||
|
|
||||||
|
| # | Question | Short answer |
|
||||||
|
|---|----------|--------------|
|
||||||
|
| 1 | Score-entry data model & persistence | Live score = `UUID -> int` in a `HashMap`. **No persistence** (load/save are `TODO` stubs). |
|
||||||
|
| 2 | How scores are recorded | **Manual, event-free:** the *administrator* runs a command. No game events, no timers. |
|
||||||
|
| 3 | High-score-table algorithm | **Not implemented.** No sort/tie-break/cap/decay. Only an aspirational `/score-archive`. |
|
||||||
|
| 4 | Scoring formulas / weighting | **None.** Scores are plain integer accumulators; no weighting, no decay, no bonuses. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The score-entry data model & persistence *(task Q1)*
|
||||||
|
|
||||||
|
### 1.1 What a "score entry" actually is (current reality)
|
||||||
|
|
||||||
|
The entire live-score domain is **one field** in the plugin's entry-point class:
|
||||||
|
|
||||||
|
- **File:** `src/main/java/com/majinnaibu/minecraft/plugins/scorekeeper/ScoreKeeperPlugin.java:38`
|
||||||
|
- **Declaration:** `private final HashMap<UUID, Integer> _playerScores = new HashMap<UUID, Integer>();`
|
||||||
|
|
||||||
|
That is the whole model:
|
||||||
|
|
||||||
|
| Aspect | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| Record shape | A single `Integer` (the score), keyed by a player's `UUID`. |
|
||||||
|
| Key type | `java.util.UUID` — the player's `UniqueId`, *not* the name string. |
|
||||||
|
| Value type | `int` (Java `Integer`), autoboxed into the map. |
|
||||||
|
| Container | one `java.util.HashMap` on the plugin instance. |
|
||||||
|
| Uniqueness | one entry per `UUID`. A player can have exactly one live score at a time. |
|
||||||
|
| Default for a new player | `0` — see §3.1 ("first-time player" edge case). |
|
||||||
|
|
||||||
|
There is **no `Score`/`ScoreEntry`/`PlayerStats` value class**, no timestamp, no
|
||||||
|
name stored alongside the score (name is looked up from the live server only — see §2.3),
|
||||||
|
and no secondary index.
|
||||||
|
|
||||||
|
### 1.2 Persistence format: **none exists**
|
||||||
|
|
||||||
|
Persistence is *intended* but *not built*. Three concrete stubs prove this:
|
||||||
|
|
||||||
|
1. **On disable (server stop):** `ScoreKeeperPlugin.java:46-49` `onDisable()` logs
|
||||||
|
*"Unable to save scores to file. This feature is not implemented yet."* — the map
|
||||||
|
is **discarded** on shutdown; nothing is written.
|
||||||
|
2. **On enable (server start):** `ScoreKeeperPlugin.java:59-60` `onEnable()` logs
|
||||||
|
*"Unable to load scores from file. This feature is not implemented yet."* — the map
|
||||||
|
always starts **empty** `{}` every server start.
|
||||||
|
3. **Archive:** `ScoreKeeperPlugin.java:72-74` `archiveScore(Player)` logs
|
||||||
|
*"Unable to archive score for <name>."* and returns — the "save to high-score table"
|
||||||
|
action is a no-op.
|
||||||
|
|
||||||
|
So there is **no SQLite, no JSON, no YAML, no flat file, no config.yml**. The only file on
|
||||||
|
disk that concerns scores is `plugin.yml` (the command manifest — it lists the commands but
|
||||||
|
stores no scores). A grep of the whole tree for `sql / json / yaml / saveResource /
|
||||||
|
YamlConfiguration / File / Files. / Gson / Jackson` finds **zero hits** other than the
|
||||||
|
TODO/log strings.
|
||||||
|
|
||||||
|
**Persistence format today = "in RAM only, lost on server restart."**
|
||||||
|
|
||||||
|
> Implication for a new engineer: any "who's the top player" question answered *across
|
||||||
|
> restarts* is impossible today. Scores reset to the map being empty on every server boot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. How scores are recorded *(task Q2)*
|
||||||
|
|
||||||
|
### 2.1 The recording model is **manual and command-driven — no events, no timers**
|
||||||
|
|
||||||
|
Scores move only when an administrator (or console/RCON) **types a command**. There is:
|
||||||
|
|
||||||
|
- **No** `implements Listener`, no `@EventHandler`, no `registerEvents(...)`, no
|
||||||
|
`getServer().getPluginManager().registerEvents(...)`.
|
||||||
|
- **No** scheduler: no `getScheduler()`, no `runTask`, no `Timer`.
|
||||||
|
- **No** automatic hook to game events (e.g. a player killing a mob or reaching a goal
|
||||||
|
awards points automatically — *nothing like that exists*).
|
||||||
|
|
||||||
|
A grep for `EventListener|@EventHandler|Listener|Scheduler|runTask` across `src/` returns
|
||||||
|
**zero**.
|
||||||
|
|
||||||
|
So "scoring" happens **on demand**, by a person, via one of the four mutating commands:
|
||||||
|
|
||||||
|
| Command | Effect on the map | Source |
|
||||||
|
|---------|-------------------|--------|
|
||||||
|
| `/score-add [player] <amount>` | `score += amount` | `ScoreKeeperPlugin.addScore` `:67-70` |
|
||||||
|
| `/score-subtract [player] <amount>` | `score -= amount` | `ScoreKeeperPlugin.subtractScore` `:88-91` |
|
||||||
|
| `/score-reset [player]` | `score = 0` | `ScoreKeeperPlugin.resetScore` `:80-82` |
|
||||||
|
| `/score-archive [player]` | **intended** to snapshot to a high-score table and reset to 0; **actually a no-op** | `ScoreKeeperPlugin.archiveScore` `:72-74` |
|
||||||
|
|
||||||
|
`/score-get [player]` is read-only (returns the score, §2.4).
|
||||||
|
|
||||||
|
### 2.2 The add/subtract primitives (the only real "write" path)
|
||||||
|
|
||||||
|
Both funnel through one private setter:
|
||||||
|
|
||||||
|
```
|
||||||
|
addScore(player, n) -> old = getPlayerScore(player); setPlayerScore(player, old + n) :67-70
|
||||||
|
subtractScore(player,n)-> old = getPlayerScore(player); setPlayerScore(player, old - n) :88-91
|
||||||
|
setScore(player, s) -> setPlayerScore(player, s) :84-86
|
||||||
|
resetScore(player) -> setPlayerScore(player, 0) :80-82
|
||||||
|
setPlayerScore(p, v) -> _playerScores.put(p.getUniqueId(), v) :108-110
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes a new engineer must internalize:
|
||||||
|
|
||||||
|
- **Read-modify-write.** `addScore`/`subtractScore` read the current value, compute, write
|
||||||
|
back in two map operations. (There is no concurrency control — see §5 risks.)
|
||||||
|
- **`int` math.** Values are Java `int`; a score can go **negative** (there is no floor at
|
||||||
|
0 — `/score-subtract 50` on a 10-score yields `-40`), and can overflow `int` only after
|
||||||
|
`~2.1e9` points, which is not a practical concern.
|
||||||
|
- **No validation of the direction.** The only validation is *syntactic*: the amount must
|
||||||
|
parse as an integer (see §2.3).
|
||||||
|
|
||||||
|
### 2.3 Command surface (who/what is allowed to record a score)
|
||||||
|
|
||||||
|
Every command executor lives in `src/main/java/.../scorekeeper/commands/` and shares one
|
||||||
|
shape: parse args, resolve the target player, call the `ScoreKeeperPlugin` method, return
|
||||||
|
`true` (consumed). Two behavioral details matter:
|
||||||
|
|
||||||
|
- **Self-target default:** if a *player* runs a command and omits the name, they target
|
||||||
|
*themselves*: `targetPlayer = (Player) sender`. (e.g. `ScoreGetCommand.java:44`,
|
||||||
|
`ScoreAddCommand.java:48`, `ScoreResetCommand.java:47-49`.)
|
||||||
|
- **RCON/console path:** if the sender is **not** a player (console/RCON), a player name
|
||||||
|
is **required** — omitting it prints usage instead of acting
|
||||||
|
(`ScoreGetCommand.java:46-48`, `ScoreAddCommand.java:45-47`,
|
||||||
|
`ScoreResetCommand.java:44-46`).
|
||||||
|
- **Name resolution:** `getServer().getPlayerExact(name)` — **exact, case-sensitive**
|
||||||
|
lookup by the *currently online* name (`ScoreGetCommand.java:50`,
|
||||||
|
`ScoreAddCommand.java:57`, `ScoreResetCommand.java:50`). A name that is not online
|
||||||
|
yields `null` → "Can't find a player with that name". Note the *map key is a UUID*, but
|
||||||
|
*lookup by name only works while that player is online* — you cannot change an offline
|
||||||
|
player's score by name today.
|
||||||
|
- **No permission nodes anywhere.** `plugin.yml` declares no `permission:`/`permissions:`
|
||||||
|
and the code checks none. The README's note ("the commands aside from get are intended
|
||||||
|
for admins … Permissions support is coming") is **intent, not enforcement** — a vanilla
|
||||||
|
player can run `/score-add` today.
|
||||||
|
|
||||||
|
### 2.4 Reading a score (`/score-get`)
|
||||||
|
|
||||||
|
`ScoreGetCommand.java:38-84` → `ScoreKeeperPlugin.getScore(player)` → `getPlayerScore(player)`
|
||||||
|
(`ScoreKeeperPlugin.java:76-78, 100-106`). It returns the live integer (0 for a known-but-
|
||||||
|
unwritten player, which also *materializes* a 0 entry, see §3.1). Output formatting
|
||||||
|
("Your score is N" vs. "PLAYER's score is N") depends only on whether the sender is the
|
||||||
|
target or not — it carries no ranking or table.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The high-score-table algorithm *(task Q3)*
|
||||||
|
|
||||||
|
### 3.1 What exists: **nothing. It is aspirational.**
|
||||||
|
|
||||||
|
There is **no high-score table, no ranking structure, no sort, no tie-break rule, no max
|
||||||
|
entry count, and no decay/rotation** anywhere in the codebase. The task's premise ("the
|
||||||
|
high-score table") reflects the **product intent** in the README, not the **code**. Concretely:
|
||||||
|
|
||||||
|
- The README (`README.md:11`) says `/score-archive` *"Saves the player's score to a
|
||||||
|
'High Scores' table and resets their current score to 0."*
|
||||||
|
- The manifest (`plugin.yml:18-20`) declares `/score-archive` with that very description.
|
||||||
|
- But the command is a stub: `ScoreArchiveCommand.java:37-39` (`onCommand` body) replies
|
||||||
|
*"archive command unimplemented"*, and the underlying `archiveScore`
|
||||||
|
(`ScoreKeeperPlugin.java:72-74`) only logs and returns — it neither writes a table nor
|
||||||
|
resets anything.
|
||||||
|
|
||||||
|
Therefore the "algorithm" is: **no-op.** A new engineer asked to "implement the high-score
|
||||||
|
table" is starting from zero; the design questions in §3.2 are **open**, not answered by code.
|
||||||
|
|
||||||
|
### 3.2 Open design questions the table must settle (no code today decides these)
|
||||||
|
|
||||||
|
Since no implementation exists, these are *decisions to be made*, recorded here so the
|
||||||
|
implementation task doesn't reinvent the debate:
|
||||||
|
|
||||||
|
- **Storage/persistence format.** The load/save TODOs (`:47`, `:59`) say "to file";
|
||||||
|
`plugin.yml`/Bukkit convention favors YAML (`YamlConfiguration`), but JSON/SQLite are all
|
||||||
|
viable. **Unchosen.** Persisting to `data/` via `getDataFolder()` is the idiomatic Bukkit
|
||||||
|
path.
|
||||||
|
- **What an entry is.** Today a live score is just `{uuid -> int}`. A high-score entry
|
||||||
|
likely needs **player name + score + (timestamp?) + (date earned?)**. Name is *not*
|
||||||
|
currently stored with the score, so a durable table must capture the name at archive
|
||||||
|
time (a UUID-only entry can't render a leaderboard without the name being resolvable).
|
||||||
|
- **Sorting / ordering.** Almost certainly **descending by score**.
|
||||||
|
- **Tie-breaking.** Undefined. Candidate rules: by *score only*; by *earliest* archived
|
||||||
|
first (stable, time-ordered); by *name* (alphabetical); or "first to reach that score."
|
||||||
|
- **Max entries / cap.** Undefined. A leaderboard needs a cap (e.g. top 10 / top 30);
|
||||||
|
today there is no cap because there is no table.
|
||||||
|
- **Decay / rotation / time window.** None, and none described in docs — so the safe
|
||||||
|
default is **"scores are permanent, no decay."**
|
||||||
|
- **Reset semantics on archive.** README promises archive *resets to 0* *and* records.
|
||||||
|
`ScoreArchiveCommand` must decide: does archiving also reset? Does it allow multi-entries
|
||||||
|
(a player appearing more than once) or one row per player? All open.
|
||||||
|
|
||||||
|
### 3.3 Full lifecycle of a score: creation → (intended) display on the high-score table
|
||||||
|
|
||||||
|
This is the acceptance-criterion walk-through. Each stage is marked **[REAL]** (in code now)
|
||||||
|
or **[GAP]** (intended, not built).
|
||||||
|
|
||||||
|
1. **Player joins / acts** — a score *doesn't* exist yet. First interaction with the map is
|
||||||
|
lazy (see stage 4a). **[REAL]**
|
||||||
|
2. **Admin records a score** — `admin: /score-add Alice 10`. Parsed in `ScoreAddCommand`,
|
||||||
|
routed to `addScore`. The first time this runs for Alice, `getPlayerScore` **creates**
|
||||||
|
her entry at 0 and returns it; then `10` is written. **[REAL]**
|
||||||
|
3. **Score accumulates** — later `/score-add Alice 5` → 15; `/score-subtract Alice 2` → 13;
|
||||||
|
`/score-reset Alice` → 0. All in the in-memory map, live only. **[REAL]**
|
||||||
|
4. **Read** — `/score-get Alice` → "Alice's score is 13". No ranking shown. **[REAL]**
|
||||||
|
5. **Server restart** — `onDisable` *cannot* save (stub), `onEnable` *cannot* load (stub);
|
||||||
|
the map is **lost**. Alice's 13 is gone; every score starts empty again. **[REAL] —
|
||||||
|
this is the current data-loss reality.**
|
||||||
|
6. **Intended: archive to the high-score table** — `admin: /score-archive Alice` should
|
||||||
|
freeze Alice's 13 into a durable, sorted leaderboard and reset her live score to 0, so
|
||||||
|
it survives restarts. **[GAP]** — currently returns "archive command unimplemented".
|
||||||
|
7. **Intended: display on the high-score table** — a command (not yet built) renders the
|
||||||
|
capped, sorted list. **[GAP]** — no such command, no structure to render.
|
||||||
|
|
||||||
|
**Edge cases (explicitly, per the acceptance criteria):**
|
||||||
|
|
||||||
|
- **First-time player / first write.** A player has no map entry until `getPlayerScore` is
|
||||||
|
called, which **inserts `0` on first access** (`ScoreKeeperPlugin.java:100-106`). So a
|
||||||
|
first `/score-add X 10` yields **10, not** `10 - 0` ambiguity, and a first `/score-subtract
|
||||||
|
X 3` yields **−3** (no minimum-0 clamp). A first `/score-get X` returns **0** and
|
||||||
|
*materializes* the entry at 0. This `get-or-create-0` behavior is the de-facto "first-time
|
||||||
|
player" rule. **[REAL]**
|
||||||
|
- **Ties.** **Undefined** because no table exists. When the table is built, decide a
|
||||||
|
tie-break (§3.2). No code today can observe or sort ties — `HashMap` gives no order. **[GAP]**
|
||||||
|
- **Table full.** **Undefined** because no cap exists. When a cap is introduced, decide
|
||||||
|
what happens to the displaced entry: drop it, or "rotate" it into overflow. No code
|
||||||
|
today enforces a maximum. **[GAP]**
|
||||||
|
- **Offline / unknown name.** `getPlayerExact(name)` returns `null` for anyone not
|
||||||
|
currently online or any misspelling → the command prints "Can't find a player with that
|
||||||
|
name" and changes nothing. You **cannot** archive or reset an *offline* player by name
|
||||||
|
today; by-UUID writes (if a future table stores names+UUIDs) would fix that. **[REAL]**
|
||||||
|
- **Negative / large scores.** No clamp; negatives allowed, `int` overflow only near
|
||||||
|
~2.1 billion. **[REAL]**
|
||||||
|
- **Double-spawn / relogin.** Keyed by `UUID`, **not name**, so a name change or two
|
||||||
|
players sharing a name can't collide on the *live* map; but a *name* is never stored,
|
||||||
|
so a future table that only persists the UUID can later fail to render a name.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Scoring formulas & weighting *(task Q4)*
|
||||||
|
|
||||||
|
**None.** There is:
|
||||||
|
|
||||||
|
- No formula, no multiplier, no combo/bonus/streak logic.
|
||||||
|
- No weighting between action types.
|
||||||
|
- No time-based decay or "points per game" normalization.
|
||||||
|
|
||||||
|
Scores are **pure integer accumulators**: `score = Σ(adds) − Σ(subtracts)` over the session,
|
||||||
|
resettable to 0 by `/score-reset` (intended: on `/score-archive`). The only "arithmetic" in
|
||||||
|
the code is the `+`/`−` in `addScore`/`subtractScore` and the `0` floor written by
|
||||||
|
`resetScore`. `int` semantics apply (wrapping overflow; no checked arithmetic). Any
|
||||||
|
formula/weighting is a **future-design** decision, not encoded today.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Data-flow diagram (text)
|
||||||
|
|
||||||
|
```
|
||||||
|
ADMINISTRATOR / CONSOLE / RCON (no game events, no scheduler, no auto-scoring)
|
||||||
|
│ types a command
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ command executors │ commands/Score*Command.java
|
||||||
|
│ parse args + resolve player│ getPlayerExact(name) ── must be ONLINE (name→UUID)
|
||||||
|
│ (self-target if player) │ no permission checks
|
||||||
|
└──────────────┬──────────────┘
|
||||||
|
│ calls
|
||||||
|
▼
|
||||||
|
┌───────────────────────────────────────────────┐
|
||||||
|
│ ScoreKeeperPlugin (entry point; plugin.yml main)│
|
||||||
|
│ │
|
||||||
|
│ addScore / subtractScore / resetScore / setScore │ read-modify-write on...
|
||||||
|
│ getScore (read) archiveScore (STUB)│
|
||||||
|
└──────────────┬───────────────────────┬──────────┘
|
||||||
|
│ read-modify-write │ (intended: snapshot→table, reset 0)
|
||||||
|
▼ ▼
|
||||||
|
live map: HashMap<UUID, int> ╳ NOT IMPLEMENTED
|
||||||
|
"score entry = one int per UUID" high-score table:
|
||||||
|
default 0, no clamp, in RAM only ─ no structure, no sort,
|
||||||
|
│ ─ no persistence,
|
||||||
|
├─ READ: /score-get → "N" ─ no cap/decay
|
||||||
|
│
|
||||||
|
└─ LIFECYCLE:
|
||||||
|
onEnable : load from file → TODO (stays {} ; lost on restart)
|
||||||
|
onDisable : save to file → TODO (map discarded at shutdown)
|
||||||
|
|
||||||
|
PERSISTENCE: none │ SCORE FORMULA: none │ EVENTS/TIMERS: none │
|
||||||
|
PERMISSIONS: none │ HIGH-SCORE TABLE: aspirational stub only
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Quick reference — "where to look first"
|
||||||
|
|
||||||
|
| I want to understand… | Go to |
|
||||||
|
|-----------------------|-------|
|
||||||
|
| The entire live data model | `ScoreKeeperPlugin.java:38` (one `HashMap<UUID,Integer>`) |
|
||||||
|
| How a score changes | `addScore`/`subtractScore`/`resetScore`/`setScore` `:67-91` |
|
||||||
|
| How a score is read | `getScore` `:76-78` → `getPlayerScore` `:100-106` (note get-or-create-0) |
|
||||||
|
| The "high-score table" | **Doesn't exist** — see `archiveScore` stub `:72-74`; design open in §3.2 |
|
||||||
|
| Persistence (save/load) | `onDisable` `:46-49`, `onEnable` `:59-60` — both `TODO`, not built |
|
||||||
|
| Command behaviors | `commands/Score*Command.java` (self-target default; RCON requires a name; exact online-name lookup) |
|
||||||
|
| What's declared/intended but not wired | `plugin.yml:18-20` (`/score-archive`), `README.md:11`, `README.md` notes |
|
||||||
|
| Build/runtime target | `build.gradle` (paper-api 1.21.7, Java 21), `plugin.yml` (`api-version: 1.21`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Risks a new engineer should know (correctness gaps in the *current* live domain)
|
||||||
|
|
||||||
|
These are real, not future-work — flagging because the doc's job is to prevent a new
|
||||||
|
developer from assuming the system is more mature than it is:
|
||||||
|
|
||||||
|
1. **Data loss on restart.** No save/load (`:47`, `:59`). Every server boot wipes all
|
||||||
|
scores. Any "leaderboard across sessions" is broken by design.
|
||||||
|
2. **No permissions.** Anyplayer can self-add points. README says this is by intent
|
||||||
|
("coming after I get archive to work") but it is not enforced.
|
||||||
|
3. **Race condition on add/subtract.** `read → compute → write` is not atomic and the
|
||||||
|
`HashMap` is not thread-safe; concurrent command executions could lose an update.
|
||||||
|
(Low likelihood today since commands are serial on one thread, but it's a latent bug for
|
||||||
|
any async/event-driven scoring that gets added.)
|
||||||
|
4. **No minimum-0 / no validation of amount sign.** Subtracts produce negatives freely.
|
||||||
|
5. **Name lookups are online-only & exact.** Can't target offline players by name;
|
||||||
|
case-sensitive; "Alice" ≠ "alice".
|
||||||
|
6. **No high-score table.** The flagship advertised feature (`/score-archive` → "High
|
||||||
|
Scores" table) is a no-op; a new engineer must not expect ranking/persistence to work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Summary for the synthesizer (`Project.md` § "domain model & scoring logic")
|
||||||
|
|
||||||
|
Distilled, code-grounded, and honest:
|
||||||
|
|
||||||
|
- **Model:** one `int` per player `UUID`, held in `ScoreKeeperPlugin._playerScores`
|
||||||
|
(`HashMap`), default `0`.
|
||||||
|
- **Recording:** manual, admin-driven commands; **no events/timers**; no permission guard.
|
||||||
|
- **Persistence:** **none** — load/save are `TODO`; scores are lost on restart.
|
||||||
|
- **High-score table:** **does not exist** — `/score-archive` is a stub; sorting/tie-break/
|
||||||
|
cap/decay are **undecided design items** (listed in §3.2).
|
||||||
|
- **Formula/weighting:** **none** — plain `int` accumulation.
|
||||||
|
- **Lifecycle:** create-on-first-access (0) → add/subtract/reset (live only) → read via
|
||||||
|
`/score-get` → *intended* archive+reset + table display **not built** → lost on restart.
|
||||||
|
- **Edge cases:** first-time/first-write ⇒ 0-or-delta, no floor; ties/table-full ⇒ undefined
|
||||||
|
(no table yet); offline/unknown name ⇒ "can't find", unchanged.
|
||||||
|
|
||||||
|
Everything below the "high-score table" line of the README is **future work**, and the
|
||||||
|
write-up's job is to make that boundary explicit so the next team member isn't misled.
|
||||||
166
docs/plugin-integration-and-event-wiring.md
Normal file
166
docs/plugin-integration-and-event-wiring.md
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# ScoreKeeper — Plugin Integration & Event/Command Reference
|
||||||
|
|
||||||
|
> Analysis for the ScoreKeeper codebase. Scope: how the plugin hooks into the
|
||||||
|
> Minecraft (Paper) server runtime — event listeners, command registrations,
|
||||||
|
> scheduled/tick logic, and inter-plugin/API surface — with a table mapping each
|
||||||
|
> trigger → handler class → effect on score data.
|
||||||
|
>
|
||||||
|
> Platform: **Paper** (`io.papermc.paper:paper-api:1.21.7-R0.1-SNAPSHOT`), Java 21,
|
||||||
|
> Gradle. Main class: `com.majinnaibu.minecraft.plugins.scorekeeper.ScoreKeeperPlugin`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. TL;DR for a new developer
|
||||||
|
|
||||||
|
ScoreKeeper is almost entirely **command-driven**. It registers **no Bukkit event
|
||||||
|
listeners**, declares **no `@EventHandler` methods, no `Listener` implementations,
|
||||||
|
and calls `getPluginManager().registerEvents(...)` nowhere**. It also schedules
|
||||||
|
**no timed tasks** (no `BukkitScheduler`/`BukkitRunnable`/tick logic).
|
||||||
|
|
||||||
|
The only things that touch score state are **five console/chat commands**
|
||||||
|
(`score-get`, `score-add`, `score-subtract`, `score-reset`, `score-archive`).
|
||||||
|
Score state lives in an **in-memory `HashMap<UUID, Integer>`** and is **not
|
||||||
|
persisted** — `onEnable`/`onDisable` have `TODO` stubs, so data is lost on restart.
|
||||||
|
|
||||||
|
There is a **`highscore` / high-score table in the project name and the task
|
||||||
|
prompt, but no such command or logic exists yet** — it is unimplemented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Event listeners (Bukkit/Adventure)
|
||||||
|
|
||||||
|
**None.** The plugin listens to no server events. Confirmed by absence of any of:
|
||||||
|
`@EventHandler`, `implements Listener`, `registerEvents(`, `PlayerJoinEvent`,
|
||||||
|
`PlayerQuitEvent`, `PlayerDeathEvent`, or any other event import/registration.
|
||||||
|
|
||||||
|
| Server event | Handler | Effect on score data |
|
||||||
|
|---|---|---|
|
||||||
|
| PlayerJoin | *none* | none |
|
||||||
|
| PlayerQuit | *none* | none |
|
||||||
|
| PlayerDeath | *none* | none |
|
||||||
|
| (all other Bukkit events) | *none* | none |
|
||||||
|
| Custom/plugin-injected events | *none* | none |
|
||||||
|
|
||||||
|
**Implication:** nothing about a player's score changes as a result of gameplay
|
||||||
|
events (joining, quitting, dying, scoring points in-game, etc.). The only way
|
||||||
|
score data changes is through the commands in §3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Command registrations & permissions
|
||||||
|
|
||||||
|
Commands are declared in `src/main/resources/plugin.yml` (no `aliases`, no
|
||||||
|
`permissions:` block, and no per-command `permission:` — so **every command is
|
||||||
|
available to every sender with no permission gating**). Each is wired to an
|
||||||
|
executor in `ScoreKeeperPlugin.onEnable()`.
|
||||||
|
|
||||||
|
Argument-resolution idiom shared by every handler:
|
||||||
|
`boolean rcon = !(sender instanceof Player);` — an RCON caller has no self
|
||||||
|
context, so the "target = self" shortcut is unavailable and a `<playerName>`
|
||||||
|
must be supplied.
|
||||||
|
|
||||||
|
`<playerName>` is resolved with `server.getPlayerExact(name)` — an **exact,
|
||||||
|
case-sensitive lookup of currently-online players only**. Score operations
|
||||||
|
therefore apply to **online players**, and an unknown/offline name yields a
|
||||||
|
"Can't find a player with that name" error (except where noted).
|
||||||
|
|
||||||
|
### Reference table — command → handler class → effect on score data
|
||||||
|
|
||||||
|
| Trigger (command) | Usage | Handler class | Delegates to | Effect on score data |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `score-get` | `/score-get [player]` | `ScoreGetCommand` | `ScoreKeeperPlugin.getScore(Player)` → `getPlayerScore` | **Read only.** Returns the player's current score. Side effect: lazily registers the player at `0` if unseen. No persistent change. |
|
||||||
|
| `score-add` | `/score-add [player] <amount>` | `ScoreAddCommand` | `ScoreKeeperPlugin.addScore(Player,int)` | `score = old + amount` (integer `amount` in `[1..]`). |
|
||||||
|
| `score-subtract` | `/score-subtract [player] <amount>` | `ScoreSubtractCommand` | `ScoreKeeperPlugin.subtractScore(Player,int)` | `score = old - amount` (can go negative). |
|
||||||
|
| `score-reset` | `/score-reset [player]` | `ScoreResetCommand` | `ScoreKeeperPlugin.resetScore(Player)` | `score = 0` (writes 0; key created if absent). |
|
||||||
|
| `score-archive` | `/score-archive [player]` | `ScoreArchiveCommand` | *nothing* | **No state change.** Handler prints `"archive command unimplemented"`. It does **not** call `ScoreKeeperPlugin.archiveScore(...)` — see §5. |
|
||||||
|
|
||||||
|
### Command behaviour details
|
||||||
|
|
||||||
|
- **Sender vs. target.** Single-arg form means "operate on the invoking player"
|
||||||
|
(`split.length == 1` → `targetPlayer = sender`). Two-arg form means
|
||||||
|
`<playerName> <amount>` (add/subtract) or `<playerName>` (get/reset). For an
|
||||||
|
RCON sender, the single-arg form is treated as missing a target and prints
|
||||||
|
usage instead.
|
||||||
|
- **Amount parsing.** `score-add`/`score-subtract` require an integer
|
||||||
|
`amount`; a non-integer prints `"amount must be an integer"`.
|
||||||
|
- **Error/usage messaging.** `echoError` (red) and `echoUsage` (colour-coded)
|
||||||
|
differ between player and RCON call sites (`/score-add` vs `score-add`).
|
||||||
|
All handlers `return true` (command handled).
|
||||||
|
- **Permissions.** None declared → no operator/permission requirement; any
|
||||||
|
player or RCON can run them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Scheduled tasks / tick-based logic
|
||||||
|
|
||||||
|
**None.** Confirmed by absence of `getScheduler()`, `runTask`,
|
||||||
|
`BukkitRunnable`, `BukkitTask`, `scheduleSync*`, or any periodic/repeating
|
||||||
|
logic. ScoreKeeper runs no background or tick-driven work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Inter-plugin dependencies & API exposure
|
||||||
|
|
||||||
|
- **Declared dependencies:** plugin.yml has no `depend`, `softdepend`, `load`,
|
||||||
|
or `load-before` → **no declared inter-plugin coupling**.
|
||||||
|
- **Build dependency:** the only third-party dependency is
|
||||||
|
`io.papermc.paper:paper-api` (marked `compileOnly`, and explicitly ignored as a
|
||||||
|
snapshot by the release plugin). No other plugin/API is referenced.
|
||||||
|
- **Public API surface.** `ScoreKeeperPlugin` is a plain `JavaPlugin` exposing
|
||||||
|
`public` methods that other plugins *could* call if they hold a reference:
|
||||||
|
- `void addScore(Player, int)`
|
||||||
|
- `void subtractScore(Player, int)`
|
||||||
|
- `int getScore(Player)`
|
||||||
|
- `void setScore(Player, int)` — **public but used by no command** (internal
|
||||||
|
write path; the only writer not reachable via a command).
|
||||||
|
- `void resetScore(Player)`
|
||||||
|
- `void archiveScore(Player)` — **public but called by nothing**; it only logs
|
||||||
|
a warning. The `/score-archive` command does **not** invoke it.
|
||||||
|
- `void sendMessage(CommandSender, Component)`, `logInfo/logWarning/logError`
|
||||||
|
- **No formal service registration.** There is no `registerService`/`asService`
|
||||||
|
(ServiceLoader), no dedicated API artifact, and no `api:` block in plugin.yml.
|
||||||
|
Exposure is by **public method surface only** — informal and not discoverable
|
||||||
|
by other plugins.
|
||||||
|
- **Persistence (unimplemented).** `onEnable` logs a warning that load-from-file
|
||||||
|
is unimplemented; `onDisable` logs that save-to-file is unimplemented. The
|
||||||
|
`HashMap<UUID,Integer>` is the sole state store and is **forgotten on restart**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Lifecycle
|
||||||
|
|
||||||
|
| Hook | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `onEnable()` | Wires the five `score-*` command executors; logs the "load not implemented" warning and an enable log line. |
|
||||||
|
| `onDisable()` | Logs the "save not implemented" warning. **All in-memory scores are lost on shutdown.** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. State model (quick reference)
|
||||||
|
|
||||||
|
- **Store:** `private HashMap<UUID,Integer> _playerScores` on `ScoreKeeperPlugin`
|
||||||
|
(one map field for the whole plugin).
|
||||||
|
- **Keying:** by `Player.getUniqueId()` (UUID), so scores are per-player and
|
||||||
|
survive name changes *within a single server run*; lost on restart.
|
||||||
|
- **Lazy init:** any read (including `get`/`score-get`) auto-creates the key at `0`.
|
||||||
|
- **Writers:** `addScore`, `subtractScore`, `resetScore`, `setScore`, all funneled
|
||||||
|
through `setPlayerScore(player, value)`.
|
||||||
|
- **Concurrency:** plain `HashMap`; safe today only because command dispatch
|
||||||
|
runs on the server's main thread. Not thread-safe for off-thread use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. "Which server trigger causes which state change" — summary
|
||||||
|
|
||||||
|
| Trigger | State change |
|
||||||
|
|---|---|
|
||||||
|
| `/score-add [player] <amount>` | `_playerScores[uuid] += amount` |
|
||||||
|
| `/score-subtract [player] <amount>` | `_playerScores[uuid] -= amount` |
|
||||||
|
| `/score-reset [player]` | `_playerScores[uuid] = 0` |
|
||||||
|
| `/score-get [player]` | none (reads; lazily registers at 0) |
|
||||||
|
| `/score-archive [player]` | none (not implemented) |
|
||||||
|
| Any Minecraft/Bukkit event (join/quit/death/etc.) | **none — no listeners exist** |
|
||||||
|
| Server tick / scheduled task | **none — no scheduler exists** |
|
||||||
|
| Server enable | no data change (registration + log only) |
|
||||||
|
| Server disable | in-memory scores discarded (no persistence) |
|
||||||
|
| `addScore`/`subtractScore`/`setScore`/`archiveScore` via the public API | same table where called; only reachable by code holding a plugin reference, not by in-game events or commands (except add/subtract/reset which do have commands) |
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
version=0.2.2-SNAPSHOT
|
version=0.2.2-SNAPSHOT
|
||||||
org.gradle.java.installations.auto-download=true
|
org.gradle.java.installations.auto-download=true
|
||||||
|
org.gradle.java.installations.auto-detect=false
|
||||||
|
org.gradle.java.installations.fromEnv=JAVA_HOME
|
||||||
|
|
||||||
|
|||||||
11
gradle/wrapper/gradle-wrapper.properties
vendored
11
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,7 +1,8 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=PROJECT
|
||||||
distributionPath=wrapper/dists
|
distributionPath=.gradle/wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=PROJECT
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=.gradle/wrapper/dists
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.9.0'
|
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = 'ScoreKeeper'
|
rootProject.name = 'ScoreKeeper'
|
||||||
|
|||||||
64
tools/backup-server.sh
Executable file
64
tools/backup-server.sh
Executable file
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Backs up plugins, config, worlds, and settings from MINECRAFT_SERVER_PATH
|
||||||
|
# to MINECRAFT_BACKUP_PATH/backup-<timestamp>[-label].tar.bz2.
|
||||||
|
#
|
||||||
|
# Usage: backup-server.sh [-f|--force] [label]
|
||||||
|
# -f Back up even if the server is running (worlds may be inconsistent).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TOOLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=setup-env.sh
|
||||||
|
source "$TOOLS_DIR/setup-env.sh"
|
||||||
|
|
||||||
|
FORCE=false
|
||||||
|
LABEL=""
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
-f|--force) FORCE=true ;;
|
||||||
|
-h|--help) sed -n '2,7p' "$0"; exit 0 ;;
|
||||||
|
-*) echo "Unknown option: $arg" >&2; exit 1 ;;
|
||||||
|
*) LABEL="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -d "$MINECRAFT_SERVER_PATH" ]] || { echo "Error: $MINECRAFT_SERVER_PATH does not exist." >&2; exit 1; }
|
||||||
|
|
||||||
|
if pgrep -f "java.*paper-.*\.jar" >/dev/null && ! $FORCE; then
|
||||||
|
echo "Error: the server is running. Stop it first (tools/stop-server.sh) or pass --force." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$MINECRAFT_SERVER_PATH"
|
||||||
|
shopt -s nullglob
|
||||||
|
|
||||||
|
ITEMS=()
|
||||||
|
[[ -d plugins ]] && ITEMS+=(plugins)
|
||||||
|
[[ -d config ]] && ITEMS+=(config)
|
||||||
|
for dir in */; do
|
||||||
|
dir="${dir%/}"
|
||||||
|
[[ -f "$dir/level.dat" ]] && ITEMS+=("$dir")
|
||||||
|
done
|
||||||
|
for f in *.yml *.yaml *.json *.properties eula.txt; do
|
||||||
|
ITEMS+=("$f")
|
||||||
|
done
|
||||||
|
|
||||||
|
if (( ${#ITEMS[@]} == 0 )); then
|
||||||
|
echo "Error: nothing to back up in $MINECRAFT_SERVER_PATH." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Keep labels filename-safe.
|
||||||
|
LABEL="${LABEL//[^A-Za-z0-9._-]/_}"
|
||||||
|
NAME="backup-$(date +%Y%m%d-%H%M%S)${LABEL:+-$LABEL}.tar.bz2"
|
||||||
|
mkdir -p "$MINECRAFT_BACKUP_PATH"
|
||||||
|
ARCHIVE="$MINECRAFT_BACKUP_PATH/$NAME"
|
||||||
|
|
||||||
|
echo "Backing up: ${ITEMS[*]}"
|
||||||
|
trap 'rm -f "$ARCHIVE.part"' EXIT
|
||||||
|
tar --exclude 'plugins/.paper-remapped' --exclude '*/session.lock' \
|
||||||
|
-cjf "$ARCHIVE.part" "${ITEMS[@]}"
|
||||||
|
mv "$ARCHIVE.part" "$ARCHIVE"
|
||||||
|
trap - EXIT
|
||||||
|
|
||||||
|
echo "Created $ARCHIVE ($(du -h "$ARCHIVE" | awk '{print $1}'))"
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Find project root (two directories above this script)
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
||||||
|
|
||||||
cd "$PROJECT_ROOT"
|
|
||||||
|
|
||||||
./gradlew build
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Check required environment variables
|
|
||||||
if [[ -z "$MINECRAFT_SERVER_PATH" || -z "$MINECRAFT_SERVER_JAR" ]]; then
|
|
||||||
echo "Error: MINECRAFT_SERVER_PATH and MINECRAFT_SERVER_JAR must be set."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "$MINECRAFT_SERVER_PATH"
|
|
||||||
|
|
||||||
# Find running server process (java with the server jar)
|
|
||||||
SERVER_PID=$(pgrep -f "java.*$MINECRAFT_SERVER_JAR" || true)
|
|
||||||
|
|
||||||
if [[ -n "$SERVER_PID" ]]; then
|
|
||||||
echo "Minecraft server is running (PID: $SERVER_PID). Stopping it..."
|
|
||||||
kill "$SERVER_PID"
|
|
||||||
sleep 5
|
|
||||||
else
|
|
||||||
echo "Minecraft server is not running."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Start the server (in a detached screen session)
|
|
||||||
pushd "${MINECRAFT_SERVER_PATH}"
|
|
||||||
java -jar "${MINECRAFT_SERVER_JAR}"
|
|
||||||
popd
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
if [[ -z "$MINECRAFT_SERVER_PATH" || -z "$MINECRAFT_SERVER_JAR" ]]; then
|
|
||||||
echo "Error: MINECRAFT_SERVER_PATH and MINECRAFT_SERVER_JAR must be set."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "$MINECRAFT_SERVER_PATH"
|
|
||||||
|
|
||||||
SERVER_PID=$(pgrep -f "java.*$MINECRAFT_SERVER_JAR" || true)
|
|
||||||
|
|
||||||
if [[ -n "$SERVER_PID" ]]; then
|
|
||||||
echo "Minecraft server is running (PID: $SERVER_PID). Stopping it..."
|
|
||||||
kill "$SERVER_PID"
|
|
||||||
sleep 5
|
|
||||||
echo "Minecraft server stopped."
|
|
||||||
else
|
|
||||||
echo "Minecraft server is not running."
|
|
||||||
fi
|
|
||||||
44
tools/build-plugin.sh
Executable file
44
tools/build-plugin.sh
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
export GRADLE_USER_HOME="${GRADLE_USER_HOME:-$PROJECT_ROOT/.gradle/user-home}"
|
||||||
|
mkdir -p "$GRADLE_USER_HOME/jdks"
|
||||||
|
|
||||||
|
ensure_java_25() {
|
||||||
|
local version="25"
|
||||||
|
local os arch archive java_bin actual_home
|
||||||
|
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Darwin) os="mac" ;;
|
||||||
|
Linux) os="linux" ;;
|
||||||
|
*) echo "Unsupported OS: $(uname -s)" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$(uname -m)" in
|
||||||
|
arm64|aarch64) arch="aarch64" ;;
|
||||||
|
x86_64|amd64) arch="x64" ;;
|
||||||
|
*) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
java_bin=$(find "$GRADLE_USER_HOME/jdks" -path '*/bin/java' -type f 2>/dev/null | head -n 1 || true)
|
||||||
|
if [[ -z "$java_bin" ]]; then
|
||||||
|
echo "Downloading Temurin JDK $version into $GRADLE_USER_HOME/jdks..."
|
||||||
|
archive="$GRADLE_USER_HOME/jdks/temurin-$version.tar.gz"
|
||||||
|
curl -fsSL "https://api.adoptium.net/v3/binary/latest/$version/ga/${os}/${arch}/jdk/hotspot/normal/eclipse" -o "$archive"
|
||||||
|
tar -xzf "$archive" -C "$GRADLE_USER_HOME/jdks"
|
||||||
|
rm -f "$archive"
|
||||||
|
java_bin=$(find "$GRADLE_USER_HOME/jdks" -path '*/bin/java' -type f 2>/dev/null | head -n 1 || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -n "$java_bin" ]] || { echo "Error: could not locate installed JDK 25." >&2; exit 1; }
|
||||||
|
actual_home="$(dirname "$(dirname "$java_bin")")"
|
||||||
|
export JAVA_HOME="$actual_home"
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_java_25
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
./gradlew build "$@"
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
# shellcheck source=setup-env.sh
|
||||||
|
source "$SCRIPT_DIR/setup-env.sh"
|
||||||
|
|
||||||
if [[ -z "$MINECRAFT_SERVER_PATH" ]]; then
|
if [[ -z "$MINECRAFT_SERVER_PATH" ]]; then
|
||||||
echo "Error: MINECRAFT_SERVER_PATH environment variable is not set."
|
echo "Error: MINECRAFT_SERVER_PATH environment variable is not set."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
PLUGIN_JAR=$(ls -t "$PROJECT_ROOT"/build/libs/*.jar 2>/dev/null | grep -v -- '-all\.jar$' | head -n1)
|
||||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
||||||
|
|
||||||
PLUGIN_JAR=$(ls -t "$PROJECT_ROOT"/build/libs/*.jar 2>/dev/null | head -n1)
|
|
||||||
if [[ ! -f "$PLUGIN_JAR" ]]; then
|
if [[ ! -f "$PLUGIN_JAR" ]]; then
|
||||||
echo "Error: No plugin jar found in $PROJECT_ROOT/build/libs. Build the plugin first."
|
echo "Error: No plugin jar found in $PROJECT_ROOT/build/libs. Build the plugin first."
|
||||||
exit 1
|
exit 1
|
||||||
44
tools/publish-local.sh
Executable file
44
tools/publish-local.sh
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
export GRADLE_USER_HOME="${GRADLE_USER_HOME:-$PROJECT_ROOT/.gradle/user-home}"
|
||||||
|
mkdir -p "$GRADLE_USER_HOME/jdks"
|
||||||
|
|
||||||
|
ensure_java_25() {
|
||||||
|
local version="25"
|
||||||
|
local os arch archive java_bin actual_home
|
||||||
|
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Darwin) os="mac" ;;
|
||||||
|
Linux) os="linux" ;;
|
||||||
|
*) echo "Unsupported OS: $(uname -s)" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$(uname -m)" in
|
||||||
|
arm64|aarch64) arch="aarch64" ;;
|
||||||
|
x86_64|amd64) arch="x64" ;;
|
||||||
|
*) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
java_bin=$(find "$GRADLE_USER_HOME/jdks" -path '*/bin/java' -type f 2>/dev/null | head -n 1 || true)
|
||||||
|
if [[ -z "$java_bin" ]]; then
|
||||||
|
echo "Downloading Temurin JDK $version into $GRADLE_USER_HOME/jdks..."
|
||||||
|
archive="$GRADLE_USER_HOME/jdks/temurin-$version.tar.gz"
|
||||||
|
curl -fsSL "https://api.adoptium.net/v3/binary/latest/$version/ga/${os}/${arch}/jdk/hotspot/normal/eclipse" -o "$archive"
|
||||||
|
tar -xzf "$archive" -C "$GRADLE_USER_HOME/jdks"
|
||||||
|
rm -f "$archive"
|
||||||
|
java_bin=$(find "$GRADLE_USER_HOME/jdks" -path '*/bin/java' -type f 2>/dev/null | head -n 1 || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -n "$java_bin" ]] || { echo "Error: could not locate installed JDK 25." >&2; exit 1; }
|
||||||
|
actual_home="$(dirname "$(dirname "$java_bin")")"
|
||||||
|
export JAVA_HOME="$actual_home"
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_java_25
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
./gradlew publishToMavenLocal "$@"
|
||||||
66
tools/publish-modrinth.sh
Executable file
66
tools/publish-modrinth.sh
Executable file
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
# Load project config; credentials stay in the project .env.
|
||||||
|
# shellcheck source=setup-env.sh
|
||||||
|
source "$SCRIPT_DIR/setup-env.sh"
|
||||||
|
|
||||||
|
# Required env:
|
||||||
|
# MODRINTH_TOKEN
|
||||||
|
# MODRINTH_PROJECT_ID or MODRINTH_PROJECT_SLUG
|
||||||
|
# MODRINTH_VERSION
|
||||||
|
# MODRINTH_GAME_VERSIONS (optional, comma-separated list, default from project set)
|
||||||
|
# MODRINTH_LOADERS (optional, comma-separated, default paper)
|
||||||
|
# MODRINTH_CHANGELOG (optional)
|
||||||
|
# Optional:
|
||||||
|
# MODRINTH_DEPENDENCIES (optional JSON array or a single JSON string)
|
||||||
|
# MODRINTH_FILE (optional path to the jar; defaults to build/libs/*.jar)
|
||||||
|
|
||||||
|
MODRINTH_TOKEN="${MODRINTH_TOKEN:-}"
|
||||||
|
MODRINTH_PROJECT_ID="${MODRINTH_PROJECT_ID:-${MODRINTH_PROJECT_SLUG:-5NYwxysp}}"
|
||||||
|
MODRINTH_VERSION="${MODRINTH_VERSION:-${VERSION:-$(grep -E '^version=' gradle.properties | head -n1 | cut -d= -f2-)}}"
|
||||||
|
MODRINTH_CHANGELOG="${MODRINTH_CHANGELOG:-Auto release via publish-modrinth.sh}"
|
||||||
|
MODRINTH_GAME_VERSIONS="${MODRINTH_GAME_VERSIONS:-1.21,1.21.1,1.21.2,1.21.3,1.21.4,1.21.5,1.21.6,1.21.7}"
|
||||||
|
MODRINTH_LOADERS="${MODRINTH_LOADERS:-paper}"
|
||||||
|
MODRINTH_FILE="${MODRINTH_FILE:-}"
|
||||||
|
MODRINTH_DEPENDENCIES="${MODRINTH_DEPENDENCIES:-}"
|
||||||
|
|
||||||
|
if [[ -z "$MODRINTH_TOKEN" ]]; then
|
||||||
|
echo "Error: MODRINTH_TOKEN is not set in $PROJECT_ROOT/.env" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$MODRINTH_FILE" ]]; then
|
||||||
|
MODRINTH_FILE=$(ls -t "$PROJECT_ROOT"/build/libs/*.jar 2>/dev/null | grep -v -- '-all\.jar$' | head -n1 || true)
|
||||||
|
fi
|
||||||
|
if [[ -z "$MODRINTH_FILE" || ! -f "$MODRINTH_FILE" ]]; then
|
||||||
|
echo "Error: no jar found for Modrinth upload. Build the project first or set MODRINTH_FILE." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
csv_to_json_array() {
|
||||||
|
jq -cn --arg csv "$1" '$csv | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))'
|
||||||
|
}
|
||||||
|
MODRINTH_GAME_VERSIONS_JSON=$(csv_to_json_array "$MODRINTH_GAME_VERSIONS")
|
||||||
|
MODRINTH_LOADERS_JSON=$(csv_to_json_array "$MODRINTH_LOADERS")
|
||||||
|
|
||||||
|
if [[ -n "$MODRINTH_DEPENDENCIES" ]]; then
|
||||||
|
MODRINTH_DEPENDENCIES_ARG=(--form "dependencies=$MODRINTH_DEPENDENCIES")
|
||||||
|
else
|
||||||
|
MODRINTH_DEPENDENCIES_ARG=()
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -fsSL \
|
||||||
|
-X POST "https://api.modrinth.com/v2/version" \
|
||||||
|
-H "Authorization: $MODRINTH_TOKEN" \
|
||||||
|
-F "project_id=$MODRINTH_PROJECT_ID" \
|
||||||
|
-F "version_number=$MODRINTH_VERSION" \
|
||||||
|
-F "changelog=$MODRINTH_CHANGELOG" \
|
||||||
|
-F "game_versions=${MODRINTH_GAME_VERSIONS_JSON}" \
|
||||||
|
-F "loaders=${MODRINTH_LOADERS_JSON}" \
|
||||||
|
-F "file=@$MODRINTH_FILE" \
|
||||||
|
"${MODRINTH_DEPENDENCIES_ARG[@]}" \
|
||||||
|
| jq .
|
||||||
68
tools/restore-server.sh
Executable file
68
tools/restore-server.sh
Executable file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Restores a backup made by backup-server.sh into MINECRAFT_SERVER_PATH.
|
||||||
|
# Each top-level item in the archive (plugins, config, worlds, settings) is
|
||||||
|
# replaced; a "pre-restore" backup of the current state is taken first.
|
||||||
|
#
|
||||||
|
# Usage: restore-server.sh [-y|--yes] [--no-backup] [backup-file|name]
|
||||||
|
# Defaults to the newest backup in MINECRAFT_BACKUP_PATH.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TOOLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=setup-env.sh
|
||||||
|
source "$TOOLS_DIR/setup-env.sh"
|
||||||
|
|
||||||
|
ASSUME_YES=false
|
||||||
|
SAFETY_BACKUP=true
|
||||||
|
ARCHIVE=""
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
-y|--yes) ASSUME_YES=true ;;
|
||||||
|
--no-backup) SAFETY_BACKUP=false ;;
|
||||||
|
-h|--help) sed -n '2,8p' "$0"; exit 0 ;;
|
||||||
|
-*) echo "Unknown option: $arg" >&2; exit 1 ;;
|
||||||
|
*) ARCHIVE="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$ARCHIVE" ]]; then
|
||||||
|
ARCHIVE=$(ls -t "$MINECRAFT_BACKUP_PATH"/backup-*.tar.bz2 2>/dev/null | head -n1 || true)
|
||||||
|
[[ -n "$ARCHIVE" ]] || { echo "Error: no backups found in $MINECRAFT_BACKUP_PATH." >&2; exit 1; }
|
||||||
|
elif [[ ! -f "$ARCHIVE" && -f "$MINECRAFT_BACKUP_PATH/$ARCHIVE" ]]; then
|
||||||
|
ARCHIVE="$MINECRAFT_BACKUP_PATH/$ARCHIVE"
|
||||||
|
fi
|
||||||
|
[[ -f "$ARCHIVE" ]] || { echo "Error: backup not found: $ARCHIVE" >&2; exit 1; }
|
||||||
|
ARCHIVE="$(cd "$(dirname "$ARCHIVE")" && pwd)/$(basename "$ARCHIVE")"
|
||||||
|
|
||||||
|
if pgrep -f "java.*paper-.*\.jar" >/dev/null; then
|
||||||
|
echo "Error: the server is running. Stop it first (tools/stop-server.sh)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ENTRIES=$(tar -tjf "$ARCHIVE")
|
||||||
|
if grep -qE '^/|(^|/)\.\.(/|$)' <<<"$ENTRIES"; then
|
||||||
|
echo "Error: archive contains absolute or '..' paths; refusing to restore." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
TOP_LEVEL=$(sed -E 's#^\./##; s#/.*##' <<<"$ENTRIES" | { grep -vE '^\.?$' || true; } | sort -u)
|
||||||
|
[[ -n "$TOP_LEVEL" ]] || { echo "Error: backup is empty." >&2; exit 1; }
|
||||||
|
|
||||||
|
echo "Restore $(basename "$ARCHIVE") into $MINECRAFT_SERVER_PATH"
|
||||||
|
echo "These items will be replaced:"
|
||||||
|
sed 's/^/ /' <<<"$TOP_LEVEL"
|
||||||
|
if ! $ASSUME_YES; then
|
||||||
|
read -r -p "Continue? [y/N] " reply
|
||||||
|
[[ "$reply" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$MINECRAFT_SERVER_PATH"
|
||||||
|
if $SAFETY_BACKUP && [[ -n "$(ls -A "$MINECRAFT_SERVER_PATH")" ]]; then
|
||||||
|
"$TOOLS_DIR/backup-server.sh" pre-restore
|
||||||
|
fi
|
||||||
|
|
||||||
|
while IFS= read -r item; do
|
||||||
|
rm -rf "${MINECRAFT_SERVER_PATH:?}/$item"
|
||||||
|
done <<<"$TOP_LEVEL"
|
||||||
|
|
||||||
|
tar -xjf "$ARCHIVE" -C "$MINECRAFT_SERVER_PATH"
|
||||||
|
echo "Restored $(basename "$ARCHIVE")."
|
||||||
39
tools/setup-env.sh
Executable file
39
tools/setup-env.sh
Executable file
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Source (bash or zsh) to export the shared server config from ../.env
|
||||||
|
# (relative to this script):
|
||||||
|
# PAPER_VERSION, PAPER_BUILD, MINECRAFT_SERVER_PATH, MINECRAFT_SERVER_JAR, MINECRAFT_BACKUP_PATH
|
||||||
|
|
||||||
|
_setup_env_self="${BASH_SOURCE[0]:-${(%):-%x}}"
|
||||||
|
_setup_env_root="$(cd "$(dirname "$_setup_env_self")/.." && pwd)"
|
||||||
|
_setup_env_file="${MINECRAFT_ENV_FILE:-$_setup_env_root/.env}"
|
||||||
|
|
||||||
|
if [[ -f "$_setup_env_file" ]]; then
|
||||||
|
while IFS= read -r _line || [[ -n "$_line" ]]; do
|
||||||
|
_line="${_line%$'\r'}"
|
||||||
|
[[ "$_line" =~ ^[[:space:]]*(#|$) ]] && continue
|
||||||
|
_key="${_line%%=*}"
|
||||||
|
_value="${_line#*=}"
|
||||||
|
_key="${_key//[[:space:]]/}"
|
||||||
|
[[ "$_key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
|
||||||
|
_value="${_value#\"}"; _value="${_value%\"}"
|
||||||
|
_value="${_value#\'}"; _value="${_value%\'}"
|
||||||
|
eval "_current=\"\${$_key:-}\""
|
||||||
|
[[ -z "$_current" ]] && export "$_key=$_value"
|
||||||
|
done < "$_setup_env_file"
|
||||||
|
fi
|
||||||
|
|
||||||
|
PAPER_VERSION="${PAPER_VERSION:-}"
|
||||||
|
PAPER_BUILD="${PAPER_BUILD:-}"
|
||||||
|
MINECRAFT_SERVER_PATH="${MINECRAFT_SERVER_PATH:-MCServer}"
|
||||||
|
MINECRAFT_SERVER_PATH="${MINECRAFT_SERVER_PATH/#\~/$HOME}"
|
||||||
|
[[ "$MINECRAFT_SERVER_PATH" == /* ]] || MINECRAFT_SERVER_PATH="$_setup_env_root/$MINECRAFT_SERVER_PATH"
|
||||||
|
MINECRAFT_BACKUP_PATH="${MINECRAFT_BACKUP_PATH:-backups}"
|
||||||
|
MINECRAFT_BACKUP_PATH="${MINECRAFT_BACKUP_PATH/#\~/$HOME}"
|
||||||
|
[[ "$MINECRAFT_BACKUP_PATH" == /* ]] || MINECRAFT_BACKUP_PATH="$_setup_env_root/$MINECRAFT_BACKUP_PATH"
|
||||||
|
|
||||||
|
if [[ -z "${MINECRAFT_SERVER_JAR:-}" && -n "$PAPER_VERSION" && -n "$PAPER_BUILD" ]]; then
|
||||||
|
MINECRAFT_SERVER_JAR="paper-${PAPER_VERSION}-${PAPER_BUILD}.jar"
|
||||||
|
fi
|
||||||
|
|
||||||
|
export PAPER_VERSION PAPER_BUILD MINECRAFT_SERVER_PATH MINECRAFT_SERVER_JAR MINECRAFT_BACKUP_PATH
|
||||||
|
unset _setup_env_self _setup_env_root _setup_env_file _line _key _value _current
|
||||||
92
tools/start-server.sh
Executable file
92
tools/start-server.sh
Executable file
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Downloads (if missing) and runs a Paper server via the Fill v3 API.
|
||||||
|
# Server settings come from ../.env relative to this script (see setup-env.sh).
|
||||||
|
#
|
||||||
|
# Env vars:
|
||||||
|
# JAVA_OPTS Extra JVM args (default: -Xms2G -Xmx2G)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TOOLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=setup-env.sh
|
||||||
|
source "$TOOLS_DIR/setup-env.sh"
|
||||||
|
|
||||||
|
[[ -n "$PAPER_VERSION" ]] || { echo "Error: PAPER_VERSION is not set (see .env)." >&2; exit 1; }
|
||||||
|
|
||||||
|
PAPER_PROJECT="paper"
|
||||||
|
JAVA_OPTS="${JAVA_OPTS:--Xms2G -Xmx2G}"
|
||||||
|
FILL_API="https://fill.papermc.io/v3/projects/${PAPER_PROJECT}/versions/${PAPER_VERSION}"
|
||||||
|
# Fill requires a non-generic User-Agent with contact info.
|
||||||
|
USER_AGENT="${PAPER_USER_AGENT:-majinnaibu-plugin-dev/1.0 (https://github.com/majinnaibu)}"
|
||||||
|
|
||||||
|
for cmd in curl jq java shasum; do
|
||||||
|
command -v "$cmd" >/dev/null || { echo "Error: '$cmd' is required." >&2; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
api_get() {
|
||||||
|
curl -fsSL -H "User-Agent: $USER_AGENT" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$MINECRAFT_SERVER_PATH"
|
||||||
|
cd "$MINECRAFT_SERVER_PATH"
|
||||||
|
|
||||||
|
JAR_BUILD="${MINECRAFT_SERVER_JAR:-}"
|
||||||
|
JAR_BUILD="${JAR_BUILD#paper-"$PAPER_VERSION"-}"; JAR_BUILD="${JAR_BUILD%.jar}"
|
||||||
|
if [[ -z "$PAPER_BUILD" && "$JAR_BUILD" =~ ^[0-9]+$ ]]; then
|
||||||
|
PAPER_BUILD="$JAR_BUILD"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$PAPER_BUILD" ]]; then
|
||||||
|
echo "Resolving latest stable build for Paper $PAPER_VERSION..."
|
||||||
|
PAPER_BUILD=$(api_get "$FILL_API/builds" \
|
||||||
|
| jq -r 'map(select(.channel == "STABLE")) | max_by(.id) | .id // empty') || true
|
||||||
|
if [[ -z "$PAPER_BUILD" ]]; then
|
||||||
|
# Offline or no stable build: fall back to the newest local jar for this version.
|
||||||
|
for f in paper-"$PAPER_VERSION"-*.jar; do
|
||||||
|
b="${f#paper-"$PAPER_VERSION"-}"; b="${b%.jar}"
|
||||||
|
[[ "$b" =~ ^[0-9]+$ ]] && (( b > ${PAPER_BUILD:-0} )) && PAPER_BUILD="$b"
|
||||||
|
done
|
||||||
|
[[ -n "$PAPER_BUILD" ]] || { echo "Error: no stable build found for $PAPER_VERSION." >&2; exit 1; }
|
||||||
|
echo "Could not resolve from API; using local build $PAPER_BUILD."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
EXPECTED_JAR="paper-${PAPER_VERSION}-${PAPER_BUILD}.jar"
|
||||||
|
if [[ -n "${MINECRAFT_SERVER_JAR:-}" && "$MINECRAFT_SERVER_JAR" != "$EXPECTED_JAR" ]]; then
|
||||||
|
echo "Error: MINECRAFT_SERVER_JAR=$MINECRAFT_SERVER_JAR does not match PAPER_VERSION/PAPER_BUILD ($EXPECTED_JAR)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
MINECRAFT_SERVER_JAR="$EXPECTED_JAR"
|
||||||
|
|
||||||
|
if [[ ! -f "$MINECRAFT_SERVER_JAR" ]]; then
|
||||||
|
echo "Downloading $MINECRAFT_SERVER_JAR..."
|
||||||
|
BUILD_JSON=$(api_get "$FILL_API/builds/$PAPER_BUILD")
|
||||||
|
DOWNLOAD_URL=$(jq -r '.downloads."server:default".url // empty' <<<"$BUILD_JSON")
|
||||||
|
EXPECTED_SHA=$(jq -r '.downloads."server:default".checksums.sha256 // empty' <<<"$BUILD_JSON")
|
||||||
|
[[ -n "$DOWNLOAD_URL" ]] || { echo "Error: no download for $PAPER_VERSION build $PAPER_BUILD." >&2; exit 1; }
|
||||||
|
|
||||||
|
TMP_JAR="$MINECRAFT_SERVER_JAR.part"
|
||||||
|
trap 'rm -f "$TMP_JAR"' EXIT
|
||||||
|
curl -fL --progress-bar -H "User-Agent: $USER_AGENT" -o "$TMP_JAR" "$DOWNLOAD_URL"
|
||||||
|
|
||||||
|
ACTUAL_SHA=$(shasum -a 256 "$TMP_JAR" | awk '{print $1}')
|
||||||
|
if [[ -n "$EXPECTED_SHA" && "$ACTUAL_SHA" != "$EXPECTED_SHA" ]]; then
|
||||||
|
echo "Error: checksum mismatch (expected $EXPECTED_SHA, got $ACTUAL_SHA)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mv "$TMP_JAR" "$MINECRAFT_SERVER_JAR"
|
||||||
|
trap - EXIT
|
||||||
|
fi
|
||||||
|
|
||||||
|
export MINECRAFT_SERVER_PATH MINECRAFT_SERVER_JAR
|
||||||
|
|
||||||
|
"$TOOLS_DIR/stop-server.sh"
|
||||||
|
|
||||||
|
if ! grep -qs '^eula=true' eula.txt; then
|
||||||
|
echo "Note: accept the EULA by setting eula=true in $MINECRAFT_SERVER_PATH/eula.txt (server will exit until then)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting $MINECRAFT_SERVER_JAR in $MINECRAFT_SERVER_PATH..."
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
exec java $JAVA_OPTS -jar "$MINECRAFT_SERVER_JAR" --nogui
|
||||||
|
|
||||||
34
tools/stop-server.sh
Executable file
34
tools/stop-server.sh
Executable file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Stops any running Paper server (java process running a paper-*.jar).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
STOP_TIMEOUT="${STOP_TIMEOUT:-60}"
|
||||||
|
|
||||||
|
SERVER_PIDS=$(pgrep -f "java.*paper-.*\.jar" || true)
|
||||||
|
|
||||||
|
if [[ -z "$SERVER_PIDS" ]]; then
|
||||||
|
echo "Minecraft server is not running."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
for pid in $SERVER_PIDS; do
|
||||||
|
echo "Stopping Minecraft server (PID: $pid)..."
|
||||||
|
# SIGTERM lets Paper run its shutdown hook and save worlds.
|
||||||
|
kill "$pid"
|
||||||
|
done
|
||||||
|
|
||||||
|
for ((i = 0; i < STOP_TIMEOUT; i++)); do
|
||||||
|
remaining=""
|
||||||
|
for pid in $SERVER_PIDS; do
|
||||||
|
kill -0 "$pid" 2>/dev/null && remaining="$remaining $pid"
|
||||||
|
done
|
||||||
|
if [[ -z "$remaining" ]]; then
|
||||||
|
echo "Minecraft server stopped."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Server did not stop within ${STOP_TIMEOUT}s; forcing:$remaining"
|
||||||
|
kill -9 $remaining
|
||||||
Reference in New Issue
Block a user