diff --git a/.changeset/brave-badgers-arrive.md b/.changeset/brave-badgers-arrive.md new file mode 100644 index 0000000..07d3acb --- /dev/null +++ b/.changeset/brave-badgers-arrive.md @@ -0,0 +1,5 @@ +--- +bump: major +--- + +Set Homes Two can now take over from Set Homes v1. /import-homes brings your homes and world blacklist across and lists any v1 config settings that have an equivalent here, the v1 commands and aliases work again, and the plugin tells you at startup when homes are still waiting to be imported. diff --git a/.changeset/brave-otters-listen.md b/.changeset/brave-otters-listen.md new file mode 100644 index 0000000..aa259f4 --- /dev/null +++ b/.changeset/brave-otters-listen.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +Tab completion for /import-homes now offers the import sources and then confirm, instead of suggesting player names on every argument. Usage messages also name the command you actually typed, so /uhome-of now says Usage: /uhome-of rather than naming the full command behind the alias. diff --git a/.changeset/brave-pandas-sing.md b/.changeset/brave-pandas-sing.md new file mode 100644 index 0000000..20dba33 --- /dev/null +++ b/.changeset/brave-pandas-sing.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +decrease generated jar file size diff --git a/.changeset/clever-tigers-wander.md b/.changeset/clever-tigers-wander.md new file mode 100644 index 0000000..304011c --- /dev/null +++ b/.changeset/clever-tigers-wander.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +If Set Homes v1 homes are sitting in plugins/SetHomes/homes.yml and you have not imported them yet, the console now says so at startup, naming the file, how many homes are waiting and the command to run. Anyone with sh2.import-homes gets the same reminder in chat when they join. Both stop on their own as soon as any home exists here, so an upgraded server can no longer look empty without explanation. diff --git a/.changeset/eager-badgers-return.md b/.changeset/eager-badgers-return.md new file mode 100644 index 0000000..d12abee --- /dev/null +++ b/.changeset/eager-badgers-return.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +Added the Set Homes v1 admin commands. /home-of teleports you to another player's home, /delhome-of deletes one, and /uhome-of moves one to where you are standing. /uhome moves one of your own homes. All of them work on players who are offline, as long as they have saved homes, and player and home names now match without regard to case. On a server upgrading from an earlier release, each player has to log in once before the offline lookup can find them. diff --git a/.changeset/eager-seals-shine.md b/.changeset/eager-seals-shine.md new file mode 100644 index 0000000..3e07cf7 --- /dev/null +++ b/.changeset/eager-seals-shine.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +/sethome and /home work with no arguments again, creating and finding a home called default, which is the name an unnamed Set Homes v1 home is imported under. /sethome also takes a description straight after the name as it did in v1, so a second word that names an item is read as the icon and the reply tells you which one it chose. Put d in the icon position to keep the whole phrase as the description. diff --git a/.changeset/gentle-dolphins-wonder.md b/.changeset/gentle-dolphins-wonder.md new file mode 100644 index 0000000..1c6df07 --- /dev/null +++ b/.changeset/gentle-dolphins-wonder.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +Tab completion now matches anywhere in a name, not only from the start, so typing net completes to world_nether and ase finds a home called base. Names that start with what you typed are still listed first, so nothing you already do changes. diff --git a/.changeset/gentle-foxes-config.md b/.changeset/gentle-foxes-config.md new file mode 100644 index 0000000..ebbf426 --- /dev/null +++ b/.changeset/gentle-foxes-config.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +/import-homes sethomes now lists any v1 config.yml settings that have a Set Homes Two equivalent, and the key to set each one under, so a migrating admin does not have to discover a changed teleport delay or a vanished per-group home limit by accident. Nothing is written to config.yml automatically; the report just tells you what to paste in. diff --git a/.changeset/happy-otters-dance.md b/.changeset/happy-otters-dance.md new file mode 100644 index 0000000..52eed20 --- /dev/null +++ b/.changeset/happy-otters-dance.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +Importing from Set Homes v1 no longer loses a home whose name differs only in capitalisation. Set Homes v1 allowed one player to hold both 'base' and 'Base', while home names here ignore case, so the second one is now imported under the next free name such as 'Base2' instead of being silently dropped. The import report and the server log both name it, and the preview now reports the same numbers as the confirm that follows it. diff --git a/.changeset/kind-herons-shine.md b/.changeset/kind-herons-shine.md new file mode 100644 index 0000000..cc6309c --- /dev/null +++ b/.changeset/kind-herons-shine.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +Corrected the spelling in a few of the messages the plugin sends, so they read the same way as the rest of the plugin. The wording of the teleport and import messages is otherwise unchanged. An existing config.yml is not touched, so any message you have already customized stays exactly as you set it. diff --git a/.changeset/kind-ravens-gather.md b/.changeset/kind-ravens-gather.md new file mode 100644 index 0000000..840503c --- /dev/null +++ b/.changeset/kind-ravens-gather.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +The plugin now sends anonymous usage counts to bStats, so the maintainers can see which commands, menu buttons and settings are used, and whether database errors are happening in the wild. Nothing personal is sent, and the bStats switch in plugins/bStats/config.yml turns it off. diff --git a/.changeset/lucky-wolves-smile.md b/.changeset/lucky-wolves-smile.md new file mode 100644 index 0000000..7162844 --- /dev/null +++ b/.changeset/lucky-wolves-smile.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +Fixed the world blacklist only ever being enforced on the first three worlds of a server. Blacklisting any world beyond those reported success and then did nothing. It is enforced from this release on, so if you have blacklisted more than three worlds, run /blacklist list before you update. Homes in a world that starts being enforced stop being reachable by players who do not hold sh2.bypass-blacklist. diff --git a/.changeset/plain-lions-report.md b/.changeset/plain-lions-report.md new file mode 100644 index 0000000..2b90156 --- /dev/null +++ b/.changeset/plain-lions-report.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +/blacklist add and /blacklist remove no longer report success when the database write actually failed. A failure now says so and the world is left as it was. Permission overrides in config.yml are also matched without regard to the case you write the node in, so a line like SH2.manage-homes now applies rather than being logged as applied and quietly ignored. diff --git a/.changeset/quiet-herons-arrive.md b/.changeset/quiet-herons-arrive.md new file mode 100644 index 0000000..bfa2fce --- /dev/null +++ b/.changeset/quiet-herons-arrive.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +Set Homes now publishes to the original Set Homes project page, so servers running the older Set Homes are offered this as an update on the page they already watch. The download is named SetHomes rather than SetHomesTwo. The plugin folder and your existing config.yml are unaffected. diff --git a/.changeset/quiet-otters-jump.md b/.changeset/quiet-otters-jump.md new file mode 100644 index 0000000..ee0f00d --- /dev/null +++ b/.changeset/quiet-otters-jump.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +/set-max-homes now has the alias /setmax, matching Set Homes v1. Its usage message also names whichever form you typed, rather than always naming /set-max-homes. diff --git a/.changeset/quiet-otters-names.md b/.changeset/quiet-otters-names.md new file mode 100644 index 0000000..173d9b6 --- /dev/null +++ b/.changeset/quiet-otters-names.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +Importing from Set Homes v1 or EssentialsX now resolves each player's name from the server's own player cache, so admin commands like /get-player-homes and /home-of work on a migrated player immediately instead of requiring them to log in first. A player the server has never seen still imports fine; they are picked up on their next join, same as before. diff --git a/.changeset/tidy-tigers-wonder.md b/.changeset/tidy-tigers-wonder.md new file mode 100644 index 0000000..2d2eedf --- /dev/null +++ b/.changeset/tidy-tigers-wonder.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +The Set Homes v1 import report no longer applies the colour codes found in v1 messages, so a coloured v1 message no longer turns the rest of the advice line red or unreadable. Codes are shown as an ampersand instead, and the note says where to copy the exact original from. Also, the "you have no homes yet" notice now uses the same wording and is shown as plain information rather than red from /homes, /list-homes and the homes menu alike. diff --git a/.changeset/tidy-wolves-travel.md b/.changeset/tidy-wolves-travel.md new file mode 100644 index 0000000..4a365de --- /dev/null +++ b/.changeset/tidy-wolves-travel.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +Permission defaults can now be changed from config.yml with no permissions plugin installed. Uncomment the permissions block and set any sh2 node to true, false, op or not-op. Two bundles, sh2.player and sh2.admin, move a whole role at once, and three new bypass permissions were added for admins: sh2.bypass-max-homes, sh2.bypass-blacklist and sh2.bypass-teleport-delay. Operators hold those three by default, the same way Set Homes v1 granted homes.config_bypass through homes.*, so an operator is not held to the teleport delay, the world blacklist or the home limit. Set any of them to false in the permissions block if you would rather they were. diff --git a/.changeset/warm-badgers-wonder.md b/.changeset/warm-badgers-wonder.md new file mode 100644 index 0000000..9e5f9d0 --- /dev/null +++ b/.changeset/warm-badgers-wonder.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +Set Homes now refuses to start when a Set Homes v1 jar is still in your plugins folder, and the console says which file to move and what to run next. Both plugins provide /sethome, /home and /delhome and v1 wins those names, so running the two side by side used to split your players' homes between them with nothing in the log to show for it. diff --git a/.changeset/wobbly-hedgehogs-import.md b/.changeset/wobbly-hedgehogs-import.md new file mode 100644 index 0000000..189af73 --- /dev/null +++ b/.changeset/wobbly-hedgehogs-import.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +/import-homes sethomes now brings across the Set Homes v1 world blacklist along with the homes, so a server that had worlds blocked in v1 does not silently lose that protection on migration. Re-running the import never blacklists a world twice. diff --git a/.github/workflows/changeset.yml b/.github/workflows/changeset.yml index 3afcb3e..31afab6 100644 --- a/.github/workflows/changeset.yml +++ b/.github/workflows/changeset.yml @@ -3,7 +3,10 @@ name: Changeset # Its own workflow rather than a job in Tests: a job skipped by an `if:` still # reports a check run, so the push event would publish a second, skipped # "changeset" result for the same commit. -on: pull_request +on: + pull_request: + # labeled and unlabeled so applying the downmerge label re-runs the gate. + types: [opened, synchronize, reopened, labeled, unlabeled] concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -24,6 +27,8 @@ jobs: env: GH_TOKEN: ${{ github.token }} PR: ${{ github.event.pull_request.number }} + PR_BASE: ${{ github.event.pull_request.base.ref }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} run: | set -euo pipefail # Written to a file rather than piped, so a failing gh call cannot be diff --git a/.github/workflows/issue-status.yml b/.github/workflows/issue-status.yml new file mode 100644 index 0000000..96a058b --- /dev/null +++ b/.github/workflows/issue-status.yml @@ -0,0 +1,114 @@ +name: Issue status + +# Moves issues across the project board as their work progresses. GitHub does +# not link an issue to a pull request based on dev, so the closing keywords are +# parsed out of the body here rather than read back from the API. +on: + push: + branches: ['issue-*'] + pull_request: + types: [opened, reopened, ready_for_review, edited, closed] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: {} + +env: + PROJECT_NUMBER: '1' + GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} + +jobs: + in-progress: + # A branch deletion after its pull request merges can also fire a push + # event, with no branch left to check out. + if: github.event_name == 'push' && github.event.deleted == false + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Move the issue along + env: + BRANCH: ${{ github.ref_name }} + run: | + set -uo pipefail + source scripts/issue-status.sh + + issue="$(printf '%s' "$BRANCH" | sed -nE 's/^issue-([0-9]+)-.*/\1/p')" + if [ -z "$issue" ]; then + echo "Branch $BRANCH does not name an issue - nothing to do." + exit 0 + fi + + # Pushing to a branch that is already under review, by merging the + # base branch in for example, must not report it as in progress. + # synchronize is not a trigger type, so this job is the only thing + # that runs on such a push. + if [ "$(gh pr list --head "$BRANCH" --state open --json number --jq 'length')" = "0" ]; then + state="none" + else + state="open-pr" + fi + + mapfile -t plan < <(push_plan "$state") + bash scripts/set-issue-status.sh "$issue" "${plan[0]}" "${plan[@]:1}" + + pull-request: + # Fork runs get no secrets, so GH_TOKEN would be empty and every external + # contribution would show a red check it has no way to fix. + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Move the referenced issues + env: + # Through the environment, never interpolated into the script: a body + # is attacker controllable text. + BODY: ${{ github.event.pull_request.body }} + MERGED: ${{ github.event.pull_request.merged }} + BASE: ${{ github.event.pull_request.base.ref }} + ACTION: ${{ github.event.action }} + run: | + set -uo pipefail + source scripts/issue-status.sh + + if [ "$ACTION" = "closed" ]; then + if [ "$MERGED" != "true" ] || [ "$BASE" != "dev" ]; then + echo "Closed without merging into dev - nothing to do." + exit 0 + fi + status="Ready for release" + # Every state except Done: a merge into dev always beats an + # earlier state, but a down-merge from master can repeat a + # Closes reference for an issue that already shipped, and that + # must not drag it backwards out of Done. + allowed=(Todo unset "In progress" "In review" "Ready for release") + else + status="In review" + # Guards an edit on an already merged pull request from pulling + # the issue back out of Ready for release or Done. + allowed=(Todo unset "In progress") + fi + + refs="$(printf '%s' "$BODY" | closing_refs)" + if [ -z "$refs" ]; then + echo "No closing references in the body - nothing to do." + exit 0 + fi + + failures=0 + while IFS= read -r issue; do + [ -n "$issue" ] || continue + if ! bash scripts/set-issue-status.sh "$issue" "$status" "${allowed[@]}"; then + printf 'FAILED: could not set #%s to %s\n' "$issue" "$status" >&2 + failures=$((failures + 1)) + fi + done <<< "$refs" + + if [ "$failures" -gt 0 ]; then + exit 1 + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c9bb304..a9b2327 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,8 @@ concurrency: permissions: contents: write + issues: write + pull-requests: read jobs: release: @@ -96,6 +98,17 @@ jobs: echo "current=$CURRENT" echo "next=$NEXT" + - name: Dry run - list the issues this release would close + if: steps.plan.outputs.release == 'true' && inputs.dry_run + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.plan.outputs.next }} + DRY_RUN: '1' + run: | + set -uo pipefail + PREV_TAG="$(git describe --tags --abbrev=0 2>/dev/null || true)" \ + bash scripts/close-released-issues.sh + - name: Dry run - resolve BukkitDev game versions if: steps.plan.outputs.release == 'true' && inputs.dry_run env: @@ -116,6 +129,17 @@ jobs: end ' + # Must run before Commit and tag creates v$NEXT, or the range below is + # empty and nothing closes. + - name: Record the previous release tag + id: prev + if: steps.plan.outputs.release == 'true' + run: | + set -uo pipefail + tag="$(git describe --tags --abbrev=0 2>/dev/null || true)" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "Previous release tag: ${tag:-none}" + - name: Commit and tag if: steps.plan.outputs.release == 'true' && !inputs.dry_run env: @@ -126,7 +150,7 @@ jobs: git add pom.xml README.md .changeset git commit -m "chore(release): $NEXT" git tag -a "v$NEXT" -m "Release $NEXT" - git push origin HEAD:master --follow-tags + git push --atomic origin HEAD:master --follow-tags git ls-remote --exit-code --tags origin "refs/tags/v$NEXT" > /dev/null \ || { echo "Tag v$NEXT did not reach the remote"; exit 1; } @@ -134,7 +158,7 @@ jobs: if: steps.plan.outputs.release == 'true' && !inputs.dry_run env: NEXT: ${{ steps.plan.outputs.next }} - run: cp "target/SetHomesTwo-$NEXT.jar" "SetHomesTwo.V$NEXT.jar" + run: cp "target/SetHomesTwo-$NEXT.jar" "SetHomes.V$NEXT.jar" - name: Publish to BukkitDev if: steps.plan.outputs.release == 'true' && !inputs.dry_run @@ -150,6 +174,18 @@ jobs: VERSION: ${{ steps.plan.outputs.next }} run: | NOTES=$(bash scripts/release.sh notes --readme README.md --version "$VERSION") - gh release create "v$VERSION" "SetHomesTwo.V$VERSION.jar" \ - --title "SetHomesTwo V$VERSION" \ + gh release create "v$VERSION" "SetHomes.V$VERSION.jar" \ + --title "Set Homes V$VERSION" \ --notes "$NOTES" + + # An issue closes when the commit that fixed it is contained in the commit + # being released. That is what keeps work sitting on dev open when an + # immediate fix ships from master, and closes an immediate fix that never + # passed through dev. + - name: Close the issues this release contains + if: steps.plan.outputs.release == 'true' && !inputs.dry_run + env: + GH_TOKEN: ${{ github.token }} + PREV_TAG: ${{ steps.prev.outputs.tag }} + VERSION: ${{ steps.plan.outputs.next }} + run: bash scripts/close-released-issues.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c56afdc..7eab0bd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,6 +28,20 @@ jobs: - name: Build and test run: mvn -B verify + - name: Prove the shaded libraries were relocated + run: | + jar="$(ls target/SetHomesTwo-*.jar | head -n 1)" + listing="$(unzip -l "$jar")" + if echo "$listing" | grep -q ' org/bstats/'; then + echo "org.bstats was not relocated"; exit 1 + fi + if ! echo "$listing" | grep -q ' com/samleighton/sethomestwo/lib/bstats/'; then + echo "relocated bstats classes are missing"; exit 1 + fi + if echo "$listing" | grep -q ' net/wesjd/'; then + echo "anvilgui was not relocated"; exit 1 + fi + - name: Run the release script tests run: bash scripts/test-release.sh @@ -37,6 +51,9 @@ jobs: - name: Run the BukkitDev publish tests run: bash scripts/test-publish-bukkitdev.sh + - name: Run the issue status parsing tests + run: bash scripts/test-issue-status.sh + # Rehearses the release on every pull request. The changelog heading went # missing in a docs change, and nothing noticed until a release ran. - name: Check a release could be applied diff --git a/README.md b/README.md index 1cf30b6..01119eb 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,20 @@ -![Set Homes Two](docs/img/logo.png) +![Set Homes](docs/img/logo.png) -**Set Homes Two gives every player a menu of their homes.** Left-click one to teleport. Right-click it to rename it, move it, change its icon, or delete it. +**Set Homes gives every player a menu of their homes.** Left-click one to teleport. Right-click it to rename it, move it, change its icon, or delete it. [Source](https://github.com/Blockframe-Studios/SetHomesTwo) | [Report a bug](https://github.com/Blockframe-Studios/SetHomesTwo/issues) | [Donate](https://www.paypal.com/donate/?business=8LXCRFX27B37C&no_recurring=0&item_name=Thanks+for+your+support.+It+helps+keep+this+plugin+up+to+date+%3A%29¤cy_code=USD) ![The homes menu, with each home shown as its own item](docs/img/homes-menu.png) -## Why Set Homes Two +## Why Set Homes - **A menu, or a list of commands.** Homes live in a chest-style GUI. Players open it with `/homes` or by right-clicking the configured "Home Item". - **Every home gets its own icon.** Pick any Minecraft item when you create a home, or change it later to whatever you are holding. A base, a mine and a farm stop looking identical. - **Rename, move and delete in-game.** Right-click any home to manage it. Deleting always asks first, so nobody loses a base to a misclick. -- **Teleports that do not kill you.** Set Homes Two checks the destination and relocates you to the nearest safe spot rather than dropping you into blocks, lava, or a fall. +- **Teleports that do not kill you.** Set Homes checks the destination and relocates you to the nearest safe spot rather than dropping you into blocks, lava, or a fall. - **Switch without losing anything.** One command imports every home from EssentialsX or Set Homes v1, and shows you exactly what it will do before it does it. - **Per-rank home limits.** Give donors more homes than default players with LuckPerms groups, or set one server-wide limit. +- **Permissions you can change from the config.** Every `sh2.*` node has a sensible default, and any of them can be moved in `config.yml`. No permissions plugin required. ## Quick start @@ -21,7 +22,56 @@ 2. Run `/sethome base` where you are standing. 3. Run `/homes` and click it. -That is genuinely the whole setup. Since 1.1.0 the player-facing permissions default to granted, so your players can create and use homes the moment the plugin loads. You only need a permissions plugin if you want per-rank home limits. +That is genuinely the whole setup. Player permissions default to granted, so your players can create and use homes the moment the plugin loads. See [Permissions](#permissions) if you want to change that. + +## Commands + +| Command | What it does | +| --- | --- | +| `/sethome [name]` | Creates a home where you stand. | +| `/home [name]` | Teleports you to a home. | +| `/homes` | Opens the homes menu. | +| `/delhome ` | Deletes a home. | +| `/uhome ` | Moves one of your homes to where you are standing. | + +Names are optional on `/sethome` and `/home`. Leave the name off and both use a home called `default`. Home names are unique per player and ignore case, so `base` and `Base` are the same home. + +
+All commands + alias + +| Command | Long form | What it does | +| --- | --- | --- | +| `/sethome [name] [icon] [description]` | `/create-home` | Creates a home where you stand. With no name it is called `default`. | +| `/home [name]` | `/go-home` | Teleports you to a home. With no name it goes to `default`. | +| `/homes` | - | Opens the homes menu. | +| `/delhome ` | `/delete-home` | Deletes a home. | +| `/uhome ` | `/move-home` | Moves one of your homes to where you are standing. | +| `/list-homes` | - | Lists your homes in chat. Click a name to teleport. | +| `/give-homes-item` | - | Gives you the item that opens the menu. | + +On `/sethome`, a second word that names a real item becomes the icon, and everything after it is the description. So `/sethome base stone house` creates `base` with a stone icon and the description "house". If you wanted the whole phrase as the description, put `d` in the icon position: `/sethome base d stone house`. The reply names the icon it chose, so there is never any guessing. +
+ +
+Admin commands + +| Command | What it does | +| --- | --- | +| `/set-max-homes [group] ` (alias `/setmax`) | Sets the home limit, per LuckPerms group or server-wide. | +| `/get-player-homes ` | Lists another player's homes. | +| `/home-of ` (long form `/go-player-home`) | Teleports you to another player's home. | +| `/delhome-of ` (long form `/delete-player-home`) | Deletes another player's home. | +| `/uhome-of ` (long form `/move-player-home`) | Moves another player's home to where you are standing. | +| `/blacklist add ` (alias `/add-to-blacklist`) | Stops homes being set in a world. | +| `/blacklist remove ` (alias `/remove-from-blacklist`) | Lifts the restriction again. | +| `/blacklist list` (alias `/get-blacklisted-dimensions`) | Shows which worlds are blacklisted. | +| `/import-homes [confirm]` | Imports homes from another plugin. Dry-run unless `confirm` is given. | + +The three blacklist commands are one command with three aliases. Nothing you already type changes: `/add-to-blacklist world_nether` still adds that world, and `/get-blacklisted-dimensions` still lists them. Give worlds by the name the server knows them by, in lower case, which on a default setup means `world`, `world_nether` and `world_the_end`. + +The three commands that take a player accept anyone who has saved homes, whether or not they are online. Tab completion only offers online players, because there is no lookup for every stored name. + +
## Managing homes @@ -40,7 +90,7 @@ Open your homes with `/homes`, or right-click the homes item. Then: ![Right-clicking a home to rename it](docs/img/rename.gif) -Home names are unique per player and ignore case, so `base` and `Base` are the same home. Management is controlled by `sh2.manage-homes`, which defaults to granted. +The management menu is controlled by `sh2.manage-homes`, which defaults to granted. Changing a home's icon works the same way. Hold the item you want and click **Set icon to held item**: @@ -54,44 +104,18 @@ By default players wait three seconds before a teleport fires, and moving cancel ![Instant teleport](docs/img/teleport-instant.gif) -Before it drops anyone anywhere, Set Homes Two checks the destination is safe to stand in. If a home has been built over, flooded with lava, or left hanging above a drop, the player is moved to the nearest safe spot instead, or the teleport is cancelled and they are told why. Turn it off with `teleportSafety: false`. - -## Coming from EssentialsX or Set Homes v1 +Before it drops anyone anywhere, Set Homes checks the destination is safe to stand in. If a home has been built over, flooded with lava, or left hanging above a drop, the player is moved to the nearest safe spot instead, or the teleport is canceled and they are told why. Turn it off with `teleportSafety: false`. -Your players keep their homes. The old plugin does not even need to be running, because the importer reads its data files directly. - -- Run `/import-homes essentialsx` (or `/import-homes sethomes`). This is a **preview only**. It reports how many homes it would import and warns about any it would skip, and changes nothing. -- Happy with the numbers? Run it again with `confirm` on the end. -- Existing homes are never overwritten, so re-running it is always safe. Homes in worlds that no longer exist are skipped with a warning naming the world. +## Permissions -Afterwards, remove the old jar. Set Homes Two provides `/sethome`, `/home` and `/delhome`, and two plugins claiming the same commands will fight over them. +Nothing here needs a permissions plugin. Out of the box, every player can create, list, teleport to and manage their own homes, and operators get everything else. -## Commands +Two bundles group the nodes, so you can move a whole role in one line: -| Command | Long form | What it does | +| Bundle | Default | Contains | | --- | --- | --- | -| `/sethome [item] [description]` | `/create-home` | Creates a home where you stand. The optional item becomes its icon. | -| `/home ` | `/go-home` | Teleports you to a home. | -| `/homes` | - | Opens the homes menu. | -| `/delhome ` | `/delete-home` | Deletes a home. | -| `/list-homes` | - | Lists your homes in chat. Click a name to teleport. | -| `/give-homes-item` | - | Gives you the item that opens the menu. | - -
-Admin commands - -| Command | What it does | -| --- | --- | -| `/set-max-homes [group] ` | Sets the home limit, per LuckPerms group or server-wide. | -| `/get-player-homes ` | Lists another player's homes. | -| `/add-to-blacklist ` | Stops homes being set in a dimension. | -| `/remove-from-blacklist ` | Lifts the restriction again. | -| `/get-blacklisted-dimensions` | Shows which dimensions are blacklisted. | -| `/import-homes [confirm]` | Imports homes from another plugin. Dry-run unless `confirm` is given. | - -
- -## Permissions +| `sh2.player` | everyone | `sh2.create-home`, `sh2.go-home`, `sh2.list-homes`, `sh2.delete-home`, `sh2.teleport`, `sh2.give-homes-item`, `sh2.manage-homes`, `sh2.move-home` | +| `sh2.admin` | OP | `sh2.player`, plus every admin and bypass node |
Full permission list @@ -105,12 +129,67 @@ Afterwards, remove the old jar. Set Homes Two provides `/sethome`, `/home` and ` | `sh2.delete-home` | everyone | Deleting your own homes | | `sh2.give-homes-item` | everyone | Getting the menu item | | `sh2.manage-homes` | everyone | Renaming, moving, re-iconing and deleting from the GUI | +| `sh2.move-home` | everyone | Moving your own home with `/uhome` | | `sh2.set-max-homes` | OP | Setting home limits | | `sh2.get-player-homes` | OP | Viewing another player's homes | -| `sh2.add-to-blacklist` | OP | Blacklisting a dimension | -| `sh2.remove-from-blacklist` | OP | Un-blacklisting a dimension | -| `sh2.get-blacklisted-dimensions` | OP | Listing blacklisted dimensions | +| `sh2.go-player-home` | OP | Teleporting to another player's home | +| `sh2.delete-player-home` | OP | Deleting another player's home | +| `sh2.move-player-home` | OP | Moving another player's home | +| `sh2.add-to-blacklist` | OP | Blacklisting a world | +| `sh2.remove-from-blacklist` | OP | Un-blacklisting a world | +| `sh2.get-blacklisted-dimensions` | OP | Listing blacklisted worlds | | `sh2.import-homes` | OP | Importing from another plugin | +| `sh2.update-notify` | OP | Being told on join that a newer release exists | +| `sh2.bypass-max-homes` | OP | Creating homes past the configured maximum, whether the limit is server-wide or per group | +| `sh2.bypass-blacklist` | OP | Creating a home in a blacklisted world, moving a home into one, and teleporting to a home already in one | +| `sh2.bypass-teleport-delay` | OP | Teleporting with no countdown, and not being canceled by moving | + +These nodes are granted by the bundles, which is why denying a bundle takes its whole set away at once. Granting or denying an individual node works exactly as the table describes. + +Note that `sh2.move-home` sits in `sh2.player`, not behind `sh2.manage-homes`. If you took `sh2.manage-homes` away to stop players relocating their homes, deny `sh2.move-home` as well or `/uhome` gives the ability back. + +
+ +### Changing permissions + +Uncomment the `permissions:` block in `config.yml` and list the nodes you want to move: + +```yaml +permissions: + sh2.manage-homes: false + sh2.get-player-homes: true + sh2.import-homes: op +``` + +Accepted values are `true` (everyone), `false` (nobody), `op` (operators only) and `not-op` (everyone except operators). A deny applies to operators too, so use `op` if you want a node gone for everyone except them. + +You can name a bundle here as well as a single node, so `sh2.player: false` moves all eight player nodes in one line. There is no wildcard, so list whatever you want changed. If a line does not seem to take effect, check the server log at startup: anything the plugin could not read is named there. + +**This only changes a default.** If you run LuckPerms or similar, an explicit grant or deny there still wins. The config block decides what happens to a player the permissions plugin says nothing about. + +Take care with `sh2.import-homes`. Anyone who can run `/import-homes confirm` can write homes for every player on the server, so granting it to everyone is a real risk. + +
+Other ways to set permissions + +**With LuckPerms**, the equivalent one-liner is: + +``` +/lp group default permission set sh2.player true +``` + +**Without touching `config.yml` at all**, the server's own `permissions.yml` can wrap the nodes in a rank of your own: + +```yaml +myserver.moderator: + default: false + children: + sh2.player: true + sh2.get-player-homes: true + sh2.go-player-home: true +``` + +Then grant `myserver.moderator` to whoever should have it.
@@ -128,20 +207,119 @@ The file is commented throughout, and every message the plugin sends can be rewr | `maxHomeEnabled` | `false` | Turn home limits on. | | `maxHomesType` | `groups` | `singular` for one server-wide limit, `groups` for per-rank limits. | | `openHomeItem` | `compass` | The item players right-click to open the menu. | -| `defaultHomeItem` | `white_wool` | Icon used when a home is created without one. | +| `defaultHomeItem` | `white_wool` | Icon a home gets when the player names none. | | `inventoryTitle` | `Your homes` | Title of the homes menu. | | `maxHomeNameLength` | `32` | Longest home name allowed. | +| `permissions` | commented out | Changes the default of any `sh2.*` node. See [Changing permissions](#changing-permissions). | Per-rank limits need [LuckPerms](https://luckperms.net/download) and `maxHomesType: groups`. That table is only the common settings. For the complete list, see [`default-config.yml`](https://github.com/Blockframe-Studios/SetHomesTwo/blob/master/src/main/resources/default-config.yml), the file your `config.yml` is first written from. Every setting the plugin has is in there, commented in place. +**Your `config.yml` is written once and never touched again.** Installing the plugin, updating the jar and restarting the server all leave the file exactly as you last saved it, so a setting added in a later release will not appear in a file written by an earlier one. Anything missing falls back to its default, so nothing breaks, but you cannot change a setting you cannot see. + +To pick a new setting up, copy the key out of [`default-config.yml`](https://github.com/Blockframe-Studios/SetHomesTwo/blob/master/src/main/resources/default-config.yml) into your file and restart. To start clean, rename your `config.yml` and restart. A fresh one is written with everything in it, and you can copy your old values across. + +## Coming from EssentialsX or Set Homes v1 + +Your players keep their homes. The old plugin does not even need to be running, because the importer reads its data files directly. + +1. **Stop the server.** +2. **Move the old plugin's jar out of `plugins/`** and keep it somewhere safe rather than deleting it. That jar is your way back. Leave its data folder exactly where it is: the importer reads `plugins/SetHomes/` or `plugins/Essentials/userdata/` and never writes to either. +3. **Start the server.** Set Homes creates its own folder and an empty database. No homes are visible yet, and the console tells you how many are waiting. +4. **Run `/import-homes sethomes`** (or `/import-homes essentialsx`). This is a **preview only**. It reports how many homes it would import and warns about any it would skip, and changes nothing. +5. **Happy with the numbers?** Run it again with `confirm` on the end. +6. **Paste any settings the config report listed** into `plugins/SetHomesTwo/config.yml`, then restart the server. Nothing is written there automatically, and there is no in-game reload, so a pasted setting does nothing until the server comes back up. + +**The jar has to move before the import, not after.** Set Homes v2 will not start while a Set Homes v1 jar is still in `plugins/`. Both plugins provide `/sethome`, `/home` and `/delhome`, and v1 usually takes priority and wins those names regardless the load order. + +**You are reminded until you import.** While `plugins/SetHomes/homes.yml` still holds homes and none have been imported here, every startup says so in the console, and anyone holding `sh2.import-homes` gets the same reminder in chat on join. Both stop for good once any home exists here. + +Existing homes are never overwritten, so re-running the import is always safe. Homes in worlds that no longer exist are skipped with a warning naming the world. + +Set Homes v1 told `base` and `Base` apart, while home names here ignore case. A player holding both keeps both: the second is imported under the next free name, so `Base` arrives as `Base2`, named in the report and the server log. + +**You can go back at any time.** The old plugin's data folder is never written to, so rolling back is mostly putting its jar back. +
+Rolling back to your old plugin + +1. **Stop the server.** +2. **Put the old plugin's jar back in `plugins/`** and take the Set Homes jar out. +3. **Start the server.** Both data folders are untouched, so the old plugin picks up where it left off. + +Homes your players created under Set Homes live only in `plugins/SetHomesTwo/database/homes.db`, which the old plugin cannot read, so they are invisible while you stay rolled back. Nothing is lost, and they come back with the plugin. Re-import when you return, to pick up anything created in the old plugin meanwhile. + +
+ +
+ +
+What else the Set Homes v1 import brings across + +- **The v1 world blacklist**, added to your Set Homes v2 blacklist alongside the homes. Re-running never adds a world twice. +- **A report of your v1 `config.yml`**, listing any setting that has an equivalent here and the key to put it under. Nothing is written to `config.yml` automatically. The table further down has the same mapping for pasting in by hand. +- **Player names**, read from the server's own player list. That means `/get-player-homes`, `/home-of`, `/delhome-of` and `/uhome-of` work on an imported player straight away, for anyone this server has seen before. A player the server has never seen imports with no name and is picked up automatically on their first join. + +
+ +
+Set Homes v1: what each command and permission became + +| Set Homes v1 | Set Homes v2 | +| --- | --- | +| `/sethome [name] [description]` | `/sethome [name] [icon] [description]` | +| `/home [name]` | `/home [name]` | +| `/homes [player]` | `/list-homes` for your own list, `/get-player-homes ` for someone else's. `/homes` now opens the menu instead. | +| `/delhome [name]` | `/delhome ` | +| `/uhome [description]` | `/uhome ` | +| `/home-of [home]` | `/home-of ` | +| `/delhome-of [home]` | `/delhome-of ` | +| `/uhome-of [home]` | `/uhome-of ` | +| `/blacklist ` | `/blacklist ` | +| `/setmax ` | `/setmax`, or the long form `/set-max-homes` | + +| v1 permission | v2 permission | +| --- | --- | +| `homes.home` | `sh2.go-home`, plus `sh2.teleport` to actually arrive | +| `homes.sethome` | `sh2.create-home` | +| `homes.delhome` | `sh2.delete-home` | +| `homes.gethomes` | `sh2.get-player-homes` | +| `homes.home-of` | `sh2.go-player-home` | +| `homes.delhome-of` | `sh2.delete-player-home` | +| `homes.uhome` | `sh2.move-home` | +| `homes.uhome-of` | `sh2.move-player-home` | +| `homes.blacklist_add` | `sh2.add-to-blacklist` | +| `homes.blacklist_remove` | `sh2.remove-from-blacklist` | +| `homes.blacklist_list` | `sh2.get-blacklisted-dimensions` | +| `homes.setmax` | `sh2.set-max-homes` | +| `homes.config_bypass` | `sh2.bypass-max-homes`, `sh2.bypass-blacklist` and `sh2.bypass-teleport-delay` | +| `homes.strike` | Nothing | +| `homes.*` | `sh2.admin` | + +Worth knowing before you copy a permissions file across: + +- **`homes.config_bypass` is three nodes now.** In v1 it let a player exceed the home limit, set homes in blacklisted worlds, and skip the teleport delay and cooldown, all at once. Grant all three `sh2.bypass-*` nodes to reproduce that. Nothing is lost on the cooldown, because v2 has no cooldown feature. +- **Your v1 unnamed home is called `default`.** The importer files it under that name, and a bare `/sethome` or `/home` uses the same name, so both keep working exactly as they did. `/home-of steve default` reaches an imported unnamed home. +- **`/sethome` takes a description straight after the name again**, as it did in v1. v2 adds an optional icon in between, so a second word naming a real item is read as the icon. `/sethome base d my main base` forces the default icon and keeps the whole phrase. +- **The one-letter aliases are not provided.** v1 registered `/h`, `/sh`, `/dh`, `/lh`, `/ho`, `/dho`, `/uh`, `/uho`, `/bl` and `/sm`. If your players are used to them, map them yourself in the server's own `commands.yml`. +- **`/homes` means something different.** In v1 it printed a chat list. In v2 it opens the homes menu, and `/list-homes` prints the chat list. + +
+
-Upgrading? Your existing config.yml will not gain the new settings +Set Homes v1: config.yml settings and their v2 equivalent -Set Homes Two never touches a `config.yml` that already exists, so settings added in a later release do not appear in a file written by an earlier one. Any missing setting quietly falls back to its default, so nothing breaks, but you cannot change a setting you cannot see. +| v1 `config.yml` | v2 `config.yml` | Note | +| --- | --- | --- | +| `tp-delay` | `delay` | direct | +| `tp-cancelOnMove` | `cancelOnMove` | direct | +| `max-homes.` | `maxHomes.` | also set `maxHomesType: groups` and `maxHomeEnabled: true`. A v1 value of `0` means unlimited; leave that group out of `maxHomes` in v2 rather than setting it to `0`, which would cap it at zero homes instead. | +| `max-homes-msg` | `maxHomesReached` | direct. v1's `§` color codes paste in unchanged | +| `tp-cancelOnMove-msg` | `movedWhileTeleporting` | direct. v1's `§` color codes paste in unchanged | +| `tp-cooldown` | none | v2 has no cooldown feature | +| `tp-cooldown-msg` | none | follows the above | -To pick one up, copy the key you want out of [`default-config.yml`](https://github.com/Blockframe-Studios/SetHomesTwo/blob/master/src/main/resources/default-config.yml) into your file and restart. To start clean, rename your `config.yml` and restart. A fresh one is written with everything in it, and you can copy your old values across. +**If your v1 server had no permissions plugin, `max-homes` was never in force.** v1 logs `Could not connect to a permissions plugin! Config setting "max-homes" will be ignored!` at startup and ignores the caps entirely, so the numbers in your v1 config may never have applied to anyone. Copying them across does not newly restrict your players either: Set Homes in `groups` mode also declines to enforce without LuckPerms, and says so at startup. Install [LuckPerms](https://luckperms.net/download) if you want per-rank limits to actually take effect.
@@ -157,7 +335,21 @@ Three ways, all equivalent: `/home `, opening `/homes` and left-clicking,
Only OPs can create homes. How do I let everyone in? -Update to 1.1.0 or later. On older versions every permission defaulted to OP; they now default to granted for players. If you use a permissions plugin that denies unlisted nodes, grant `sh2.create-home`, `sh2.go-home` and `sh2.teleport`. +Update to 1.1.0 or later. On older versions every permission defaulted to OP; they now default to granted for players. If you use a permissions plugin that denies unlisted nodes, grant `sh2.player`, which covers every ordinary player node in one go. + +
+ +
+How do I turn a permission off without installing a permissions plugin? + +See [Changing permissions](#changing-permissions). + +
+ +
+A player says one of their homes shows "Cannot teleport here: dimension blacklisted". Why? + +The world that home is in has been blacklisted, so the home is listed but not reachable. Check `/blacklist list`. Either take the world off the list with `/blacklist remove `, or move the home somewhere else with `/uhome-of ` while standing where it should go.
@@ -171,7 +363,9 @@ Install LuckPerms, set `maxHomeEnabled: true` and `maxHomesType: groups`, then r
Can I run it alongside EssentialsX? -Not comfortably. Both register `/sethome`, `/home` and `/delhome`, and whichever loads last wins. Import your homes, then remove EssentialsX. +You can, but the two will split three command names between them. EssentialsX declares `/sethome`, `/home` and `/delhome` as its own commands, while Set Homes declares them as aliases of `/create-home`, `/go-home` and `/delete-home`. Bukkit never lets an alias take a name another plugin already owns, so EssentialsX keeps all three whatever the load order, and homes set with them go into EssentialsX's files while `/homes` reads ours. + +Unlike Set Homes v1, EssentialsX does not stop Set Homes from starting, so nothing warns you about the split. If you want Set Homes handling homes, follow the migration steps above, moving the EssentialsX jar out before you import. If you keep EssentialsX for everything else it does, disable those three commands in its own config instead. Set Homes' own names, `/homes`, `/list-homes`, `/create-home`, `/go-home` and `/delete-home`, always reach Set Homes either way.
diff --git a/docs/generated/listing-bukkitdev.html b/docs/generated/listing-bukkitdev.html deleted file mode 100644 index 94f5cba..0000000 --- a/docs/generated/listing-bukkitdev.html +++ /dev/null @@ -1,174 +0,0 @@ -

Set Homes Two

- -

Set Homes Two gives every player a menu of their homes. Left-click one to teleport. Right-click it to rename it, move it, change its icon, or delete it.

- -

Source | Report a bug | Donate

- -

The homes menu, with each home shown as its own item

- -

Why Set Homes Two

- -
    -
  • A menu, or a list of commands. Homes live in a chest-style GUI. Players open it with /homes or by right-clicking the configured "Home Item".
  • -
  • Every home gets its own icon. Pick any Minecraft item when you create a home, or change it later to whatever you are holding. A base, a mine and a farm stop looking identical.
  • -
  • Rename, move and delete in-game. Right-click any home to manage it. Deleting always asks first, so nobody loses a base to a misclick.
  • -
  • Teleports that do not kill you. Set Homes Two checks the destination and relocates you to the nearest safe spot rather than dropping you into blocks, lava, or a fall.
  • -
  • Switch without losing anything. One command imports every home from EssentialsX or Set Homes v1, and shows you exactly what it will do before it does it.
  • -
  • Per-rank home limits. Give donors more homes than default players with LuckPerms groups, or set one server-wide limit.
  • -
- -

Quick start

- -
    -
  1. Drop the jar into your plugins folder and restart the server.
  2. -
  3. Run /sethome base where you are standing.
  4. -
  5. Run /homes and click it.
  6. -
- -

That is genuinely the whole setup. Since 1.1.0 the player-facing permissions default to granted, so your players can create and use homes the moment the plugin loads. You only need a permissions plugin if you want per-rank home limits.

- -

Managing homes

- -

The per-home management menu

- -

Open your homes with /homes, or right-click the homes item. Then:

- - - - - - - - - -
ActionWhat happens
Left-click a homeTeleports you there
Right-click a homeOpens the management menu below
RenameOpens an anvil prompt - type the new name
Move home hereRepoints the home at where you are standing
Set icon to held itemThe home's icon becomes whatever you are holding
DeleteAsks for confirmation first
- -

Right-clicking a home to rename it

- -

Home names are unique per player and ignore case, so base and Base are the same home. Management is controlled by sh2.manage-homes, which defaults to granted.

- -

Changing a home's icon works the same way - hold the item you want and click Set icon to held item:

- -

Changing a home's icon to the item being held

- -

Teleporting

- -

The stand-still countdown before a teleport

- -

By default players wait three seconds before a teleport fires, and moving cancels it - so a home is not a free escape from a fight. Set delay: 0 for instant teleports, or cancelOnMove: false to let players walk during the countdown.

- -

Instant teleport

- -

Before it drops anyone anywhere, Set Homes Two checks the destination is safe to stand in. If a home has been built over, flooded with lava, or left hanging above a drop, the player is moved to the nearest safe spot instead, or the teleport is cancelled and they are told why. Turn it off with teleportSafety: false.

- -

Coming from EssentialsX or Set Homes v1

- -

Your players keep their homes. The old plugin does not even need to be running - the importer reads its data files directly.

- -
    -
  • Run /import-homes essentialsx (or /import-homes sethomes). This is a preview only. It reports how many homes it would import and warns about any it would skip, and changes nothing.
  • -
  • Happy with the numbers? Run it again with confirm on the end.
  • -
  • Existing homes are never overwritten, so re-running it is always safe. Homes in worlds that no longer exist are skipped with a warning naming the world.
  • -
- -

Afterwards, remove the old jar. Set Homes Two provides /sethome, /home and /delhome, and two plugins claiming the same commands will fight over them.

- -

Commands

- - - - - - - - - -
CommandLong formWhat it does
/sethome <name> [item] [description]/create-homeCreates a home where you stand. The optional item becomes its icon.
/home <name>/go-homeTeleports you to a home.
/homes-Opens the homes menu.
/delhome <name>/delete-homeDeletes a home.
/list-homes-Lists your homes in chat. Click a name to teleport.
/give-homes-item-Gives you the item that opens the menu.
- -

Admin commands

- - - - - - - - -
CommandWhat it does
/set-max-homes [group] <number>Sets the home limit, per LuckPerms group or server-wide.
/get-player-homes <player>Lists another player's homes.
/add-to-blacklist <dimension...>Stops homes being set in a dimension.
/remove-from-blacklist <dimension...>Lifts the restriction again.
/get-blacklisted-dimensionsShows which dimensions are blacklisted.
/import-homes <sethomes|essentialsx> [confirm]Imports homes from another plugin. Dry-run unless confirm is given.
- -

Permissions

- -

Full permission list

- - - - - - - - - - - - - - - -
PermissionDefaultAllows
sh2.create-homeeveryoneCreating homes
sh2.go-homeeveryoneUsing the go-home command
sh2.teleporteveryoneActually teleporting to a home
sh2.list-homeseveryoneListing homes in chat, and /homes
sh2.delete-homeeveryoneDeleting your own homes
sh2.give-homes-itemeveryoneGetting the menu item
sh2.manage-homeseveryoneRenaming, moving, re-iconing and deleting from the GUI
sh2.set-max-homesOPSetting home limits
sh2.get-player-homesOPViewing another player's homes
sh2.add-to-blacklistOPBlacklisting a dimension
sh2.remove-from-blacklistOPUn-blacklisting a dimension
sh2.get-blacklisted-dimensionsOPListing blacklisted dimensions
sh2.import-homesOPImporting from another plugin
- -

Configuration

- -

Settings live in plugins/SetHomesTwo/config.yml on your server, written the first time the plugin starts. Edit it in any text editor, save, then restart the server - there is no in-game reload command, so changes do not apply until the server comes back up.

- -

The file is commented throughout, and every message the plugin sends can be rewritten in it. These are the settings most servers actually change:

- - - - - - - - - - - - -
SettingDefaultWhat it does
delay3Seconds you must stand still before teleporting. 0 is instant.
cancelOnMovetrueCancel the teleport if the player moves during the countdown.
teleportSafetytrueRelocate to the nearest safe spot instead of teleporting into danger.
maxHomeEnabledfalseTurn home limits on.
maxHomesTypegroupssingular for one server-wide limit, groups for per-rank limits.
openHomeItemcompassThe item players right-click to open the menu.
defaultHomeItemwhite_woolIcon used when a home is created without one.
inventoryTitleYour homesTitle of the homes menu.
maxHomeNameLength32Longest home name allowed.
- -

Per-rank limits need LuckPerms and maxHomesType: groups. The full annotated config is on GitHub.

- -

Upgrading? Your existing config.yml will not gain the new settings

-

Set Homes Two never touches a config.yml that already exists, so settings added in a later release do not appear in a file written by an earlier one. Any missing setting quietly falls back to its default, so nothing breaks - but you cannot change a setting you cannot see.

- -

To pick one up, copy the key you want out of the full config into your file and restart. To start clean, rename your config.yml and restart - a fresh one is written with everything in it, and you can copy your old values across.

- -

FAQ

- -

How do my players teleport home?

-

Three ways, all equivalent: /home <name>, opening /homes and left-clicking, or right-clicking the assigned "Home Item" from /give-homes-item.

- -

Only OPs can create homes. How do I let everyone in?

-

Update to 1.1.0 or later. On older versions every permission defaulted to OP; they now default to granted for players. If you use a permissions plugin that denies unlisted nodes, grant sh2.create-home, sh2.go-home and sh2.teleport.

- -

How do I give donors more homes than everyone else?

-

Install LuckPerms, set maxHomeEnabled: true and maxHomesType: groups, then run /set-max-homes <group> <number> for each rank.

- -

Can I run it alongside EssentialsX?

-

Not comfortably - both register /sethome, /home and /delhome, and whichever loads last wins. Import your homes, then remove EssentialsX.

- -

Where are homes stored?

-

In a SQLite database in plugins/SetHomesTwo/. Nothing external to install and nothing to configure.

- -

Requirements

- -
    -
  • Paper or Spigot 1.21+
  • -
  • Java 21, which Minecraft 1.21 servers already require
  • -
  • Optional: LuckPerms, only for per-rank home limits
  • -
- -

Support

- -

Found a bug or want a feature? Open an issue on GitHub - it gets seen faster than a comment on this page.

- -

Source | Report a bug | Donate

diff --git a/docs/generated/listing-bukkitdev.md b/docs/generated/listing-bukkitdev.md new file mode 100644 index 0000000..49bdd40 --- /dev/null +++ b/docs/generated/listing-bukkitdev.md @@ -0,0 +1,114 @@ +![Set Homes](https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/dev/docs/img/logo.png) + +**Set Homes gives every player a menu of their homes.** Left-click one to teleport. Right-click it to rename it, move it, change its icon, or delete it. + +[Full documentation](https://github.com/Blockframe-Studios/SetHomesTwo#readme) | [Report a bug](https://github.com/Blockframe-Studios/SetHomesTwo/issues) | [Donate](https://www.paypal.com/donate/?business=8LXCRFX27B37C&no_recurring=0&item_name=Thanks+for+your+support.+It+helps+keep+this+plugin+up+to+date+%3A%29¤cy_code=USD) + +![The homes menu, with each home shown as its own item](https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/dev/docs/img/homes-menu.png) + +## Why Set Homes + +- **A menu, or a list of commands.** Homes live in a chest-style GUI. Players open it with `/homes` or by right-clicking the configured "Home Item". +- **Every home gets its own icon.** Pick any Minecraft item when you create a home, or change it later to whatever you are holding. A base, a mine and a farm stop looking identical. +- **Rename, move and delete in-game.** Right-click any home to manage it. Deleting always asks first, so nobody loses a base to a misclick. +- **Teleports that do not kill you.** Set Homes checks the destination and relocates you to the nearest safe spot rather than dropping you into blocks, lava, or a fall. +- **Switch without losing anything.** One command imports every home from EssentialsX or Set Homes v1, and shows you exactly what it will do before it does it. +- **Per-rank home limits.** Give donors more homes than default players with LuckPerms groups, or set one server-wide limit. +- **Permissions you can change from the config.** Every `sh2.*` node has a sensible default, and any of them can be moved in `config.yml`. No permissions plugin required. + +## Quick start + +1. Drop the jar into your `plugins` folder and restart the server. +2. Run `/sethome base` where you are standing. +3. Run `/homes` and click it. + +That is genuinely the whole setup. Player permissions default to granted, so your players can create and use homes the moment the plugin loads. + +## Commands + +| Command | What it does | +| --- | --- | +| `/sethome [name]` | Creates a home where you stand. | +| `/home [name]` | Teleports you to a home. | +| `/homes` | Opens the homes menu. | +| `/delhome ` | Deletes a home. | +| `/uhome ` | Moves one of your homes to where you are standing. | + +Names are optional on `/sethome` and `/home`. Leave the name off and both use a home called `default`. + +Admins also get `/set-max-homes`, `/blacklist`, `/import-homes`, and commands to view, visit, move and delete other players' homes. The [full command list](https://github.com/Blockframe-Studios/SetHomesTwo#commands), with long forms and aliases, is on GitHub. + +## Managing homes + +![The per-home management menu](https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/dev/docs/img/manage-menu.png) + +Open your homes with `/homes`, or right-click the homes item. Left-click a home to teleport, right-click it to rename it, move it to where you are standing, set its icon to the item you are holding, or delete it (with a confirmation). + +![Right-clicking a home to rename it](https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/dev/docs/img/rename.gif) + +![Changing a home's icon to the item being held](https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/dev/docs/img/change-icon.gif) + +## Teleporting + +By default players wait three seconds before a teleport fires, and moving cancels it, so a home is not a free escape from a fight. Set `delay: 0` for instant teleports, or `cancelOnMove: false` to let players walk during the countdown. + +Before it drops anyone anywhere, Set Homes checks the destination is safe to stand in. If a home has been built over, flooded with lava, or left hanging above a drop, the player is moved to the nearest safe spot instead. Turn it off with `teleportSafety: false`. + +## Permissions + +Nothing here needs a permissions plugin. Out of the box, every player can create, list, teleport to and manage their own homes, and operators get everything else. Two bundles cover it: + +| Bundle | Default | Contains | +| --- | --- | --- | +| `sh2.player` | everyone | Every node an ordinary player needs | +| `sh2.admin` | OP | `sh2.player`, plus every admin and bypass node | + +To change a default without a permissions plugin, uncomment the `permissions:` block in `config.yml`: + +```yaml +permissions: + sh2.manage-homes: false + sh2.get-player-homes: true + sh2.import-homes: op +``` + +Accepted values are `true`, `false`, `op` and `not-op`. If you run LuckPerms or similar, an explicit grant or deny there still wins. The [full permission list](https://github.com/Blockframe-Studios/SetHomesTwo#permissions) is on GitHub. + +## Configuration + +Settings live in `plugins/SetHomesTwo/config.yml`, written the first time the plugin starts. Edit it, save, then restart the server. The file is commented throughout, and every message the plugin sends can be rewritten in it. The settings most servers change: + +| Setting | Default | What it does | +| --- | --- | --- | +| `delay` | `3` | Seconds you must stand still before teleporting. `0` is instant. | +| `cancelOnMove` | `true` | Cancel the teleport if the player moves during the countdown. | +| `teleportSafety` | `true` | Relocate to the nearest safe spot instead of teleporting into danger. | +| `maxHomeEnabled` | `false` | Turn home limits on. | +| `maxHomesType` | `groups` | `singular` for one server-wide limit, `groups` for per-rank limits. | +| `openHomeItem` | `compass` | The item players right-click to open the menu. | + +Per-rank limits need [LuckPerms](https://luckperms.net/download). Everything else is in [`default-config.yml`](https://github.com/Blockframe-Studios/SetHomesTwo/blob/master/src/main/resources/default-config.yml), the file your `config.yml` is first written from. + +## Coming from EssentialsX or Set Homes v1 + +Your players keep their homes. The old plugin does not even need to be running, because the importer reads its data files directly. + +1. Run `/import-homes essentialsx` (or `/import-homes sethomes`). This is a **preview only**. It reports what it would import and skip, and changes nothing. +2. Happy with the numbers? Run it again with `confirm` on the end. +3. Remove the old jar. This plugin provides `/sethome`, `/home` and `/delhome`, and two plugins claiming the same commands will fight over them. + +Existing homes are never overwritten, so re-running the import is always safe. Coming from Set Homes v1, the world blacklist comes across too, and the [migration guide](https://github.com/Blockframe-Studios/SetHomesTwo#coming-from-essentialsx-or-set-homes-v1) on GitHub maps every v1 command, permission and config setting to its v2 equivalent. + +## Requirements + +- Paper or Spigot **1.21+** +- **Java 21**, which Minecraft 1.21 servers already require +- Optional: [LuckPerms](https://luckperms.net/download), only for per-rank home limits + +## Support + +The [README on GitHub](https://github.com/Blockframe-Studios/SetHomesTwo#readme) has the full command and permission lists, the migration tables, an FAQ and the changelog. + +Found a bug or want a feature? Open an issue on [GitHub](https://github.com/Blockframe-Studios/SetHomesTwo/issues). It gets seen faster than a comment on this page. + +[Source](https://github.com/Blockframe-Studios/SetHomesTwo) | [Report a bug](https://github.com/Blockframe-Studios/SetHomesTwo/issues) | [Donate](https://www.paypal.com/donate/?business=8LXCRFX27B37C&no_recurring=0&item_name=Thanks+for+your+support.+It+helps+keep+this+plugin+up+to+date+%3A%29¤cy_code=USD) diff --git a/docs/generated/listing-modrinth.md b/docs/generated/listing-modrinth.md index d13571c..5020336 100644 --- a/docs/generated/listing-modrinth.md +++ b/docs/generated/listing-modrinth.md @@ -1,19 +1,20 @@ -![Set Homes Two](https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/master/docs/img/logo.png) +![Set Homes](https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/dev/docs/img/logo.png) -**Set Homes Two gives every player a menu of their homes.** Left-click one to teleport. Right-click it to rename it, move it, change its icon, or delete it. +**Set Homes gives every player a menu of their homes.** Left-click one to teleport. Right-click it to rename it, move it, change its icon, or delete it. -[Source](https://github.com/Blockframe-Studios/SetHomesTwo) | [Report a bug](https://github.com/Blockframe-Studios/SetHomesTwo/issues) | [Donate](https://www.paypal.com/donate/?business=8LXCRFX27B37C&no_recurring=0&item_name=Thanks+for+your+support.+It+helps+keep+this+plugin+up+to+date+%3A%29¤cy_code=USD) +[Full documentation](https://github.com/Blockframe-Studios/SetHomesTwo#readme) | [Report a bug](https://github.com/Blockframe-Studios/SetHomesTwo/issues) | [Donate](https://www.paypal.com/donate/?business=8LXCRFX27B37C&no_recurring=0&item_name=Thanks+for+your+support.+It+helps+keep+this+plugin+up+to+date+%3A%29¤cy_code=USD) -![The homes menu, with each home shown as its own item](https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/master/docs/img/homes-menu.png) +![The homes menu, with each home shown as its own item](https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/dev/docs/img/homes-menu.png) -## Why Set Homes Two +## Why Set Homes - **A menu, or a list of commands.** Homes live in a chest-style GUI. Players open it with `/homes` or by right-clicking the configured "Home Item". - **Every home gets its own icon.** Pick any Minecraft item when you create a home, or change it later to whatever you are holding. A base, a mine and a farm stop looking identical. - **Rename, move and delete in-game.** Right-click any home to manage it. Deleting always asks first, so nobody loses a base to a misclick. -- **Teleports that do not kill you.** Set Homes Two checks the destination and relocates you to the nearest safe spot rather than dropping you into blocks, lava, or a fall. -- **Switch without losing anything.** One command imports every home from EssentialsX or Set Homes v1, and shows you exactly what it will do before it does it. +- **Teleports that do not kill you.** Set Homes checks the destination and relocates you to the nearest safe spot rather than dropping you into blocks, lava, or a fall. +- **Switch without losing anything.** `/import-homes essentialsx` brings every EssentialsX home across, and previews exactly what it will do before it does it. Then remove EssentialsX, since both plugins claim `/sethome` and `/home`. - **Per-rank home limits.** Give donors more homes than default players with LuckPerms groups, or set one server-wide limit. +- **Permissions you can change from the config.** Every `sh2.*` node has a sensible default, and any of them can be moved in `config.yml`. No permissions plugin required. ## Quick start @@ -21,104 +22,61 @@ 2. Run `/sethome base` where you are standing. 3. Run `/homes` and click it. -That is genuinely the whole setup. Since 1.1.0 the player-facing permissions default to granted, so your players can create and use homes the moment the plugin loads. You only need a permissions plugin if you want per-rank home limits. - -## Managing homes - -![The per-home management menu](https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/master/docs/img/manage-menu.png) +That is genuinely the whole setup. Player permissions default to granted, so your players can create and use homes the moment the plugin loads. -Open your homes with `/homes`, or right-click the homes item. Then: +## Commands -| Action | What happens | +| Command | What it does | | --- | --- | -| Left-click a home | Teleports you there | -| Right-click a home | Opens the management menu below | -| Rename | Opens an anvil prompt - type the new name | -| Move home here | Repoints the home at where you are standing | -| Set icon to held item | The home's icon becomes whatever you are holding | -| Delete | Asks for confirmation first | +| `/sethome [name]` | Creates a home where you stand. | +| `/home [name]` | Teleports you to a home. | +| `/homes` | Opens the homes menu. | +| `/delhome ` | Deletes a home. | +| `/uhome ` | Moves one of your homes to where you are standing. | -![Right-clicking a home to rename it](https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/master/docs/img/rename.gif) +Names are optional on `/sethome` and `/home`. Leave the name off and both use a home called `default`. -Home names are unique per player and ignore case, so `base` and `Base` are the same home. Management is controlled by `sh2.manage-homes`, which defaults to granted. +Admins also get `/set-max-homes`, `/blacklist`, `/import-homes`, and commands to view, visit, move and delete other players' homes. The [full command list](https://github.com/Blockframe-Studios/SetHomesTwo#commands), with long forms and aliases, is on GitHub. -Changing a home's icon works the same way - hold the item you want and click **Set icon to held item**: - -![Changing a home's icon to the item being held](https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/master/docs/img/change-icon.gif) - -## Teleporting +## Managing homes -![The stand-still countdown before a teleport](https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/master/docs/img/teleport-delay.gif) +![The per-home management menu](https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/dev/docs/img/manage-menu.png) -By default players wait three seconds before a teleport fires, and moving cancels it - so a home is not a free escape from a fight. Set `delay: 0` for instant teleports, or `cancelOnMove: false` to let players walk during the countdown. +Open your homes with `/homes`, or right-click the homes item. Left-click a home to teleport, right-click it to rename it, move it to where you are standing, set its icon to the item you are holding, or delete it (with a confirmation). -![Instant teleport](https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/master/docs/img/teleport-instant.gif) +![Right-clicking a home to rename it](https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/dev/docs/img/rename.gif) -Before it drops anyone anywhere, Set Homes Two checks the destination is safe to stand in. If a home has been built over, flooded with lava, or left hanging above a drop, the player is moved to the nearest safe spot instead, or the teleport is cancelled and they are told why. Turn it off with `teleportSafety: false`. +![Changing a home's icon to the item being held](https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/dev/docs/img/change-icon.gif) -## Coming from EssentialsX or Set Homes v1 +## Teleporting -Your players keep their homes. The old plugin does not even need to be running - the importer reads its data files directly. +By default players wait three seconds before a teleport fires, and moving cancels it, so a home is not a free escape from a fight. Set `delay: 0` for instant teleports, or `cancelOnMove: false` to let players walk during the countdown. -- Run `/import-homes essentialsx` (or `/import-homes sethomes`). This is a **preview only**. It reports how many homes it would import and warns about any it would skip, and changes nothing. -- Happy with the numbers? Run it again with `confirm` on the end. -- Existing homes are never overwritten, so re-running it is always safe. Homes in worlds that no longer exist are skipped with a warning naming the world. +Before it drops anyone anywhere, Set Homes checks the destination is safe to stand in. If a home has been built over, flooded with lava, or left hanging above a drop, the player is moved to the nearest safe spot instead. Turn it off with `teleportSafety: false`. -Afterwards, remove the old jar. Set Homes Two provides `/sethome`, `/home` and `/delhome`, and two plugins claiming the same commands will fight over them. +## Permissions -## Commands +Nothing here needs a permissions plugin. Out of the box, every player can create, list, teleport to and manage their own homes, and operators get everything else. Two bundles cover it: -| Command | Long form | What it does | +| Bundle | Default | Contains | | --- | --- | --- | -| `/sethome [item] [description]` | `/create-home` | Creates a home where you stand. The optional item becomes its icon. | -| `/home ` | `/go-home` | Teleports you to a home. | -| `/homes` | - | Opens the homes menu. | -| `/delhome ` | `/delete-home` | Deletes a home. | -| `/list-homes` | - | Lists your homes in chat. Click a name to teleport. | -| `/give-homes-item` | - | Gives you the item that opens the menu. | - -
-Admin commands - -| Command | What it does | -| --- | --- | -| `/set-max-homes [group] ` | Sets the home limit, per LuckPerms group or server-wide. | -| `/get-player-homes ` | Lists another player's homes. | -| `/add-to-blacklist ` | Stops homes being set in a dimension. | -| `/remove-from-blacklist ` | Lifts the restriction again. | -| `/get-blacklisted-dimensions` | Shows which dimensions are blacklisted. | -| `/import-homes [confirm]` | Imports homes from another plugin. Dry-run unless `confirm` is given. | +| `sh2.player` | everyone | Every node an ordinary player needs | +| `sh2.admin` | OP | `sh2.player`, plus every admin and bypass node | -
+To change a default without a permissions plugin, uncomment the `permissions:` block in `config.yml`: -## Permissions +```yaml +permissions: + sh2.manage-homes: false + sh2.get-player-homes: true + sh2.import-homes: op +``` -
-Full permission list - -| Permission | Default | Allows | -| --- | --- | --- | -| `sh2.create-home` | everyone | Creating homes | -| `sh2.go-home` | everyone | Using the go-home command | -| `sh2.teleport` | everyone | Actually teleporting to a home | -| `sh2.list-homes` | everyone | Listing homes in chat, and `/homes` | -| `sh2.delete-home` | everyone | Deleting your own homes | -| `sh2.give-homes-item` | everyone | Getting the menu item | -| `sh2.manage-homes` | everyone | Renaming, moving, re-iconing and deleting from the GUI | -| `sh2.set-max-homes` | OP | Setting home limits | -| `sh2.get-player-homes` | OP | Viewing another player's homes | -| `sh2.add-to-blacklist` | OP | Blacklisting a dimension | -| `sh2.remove-from-blacklist` | OP | Un-blacklisting a dimension | -| `sh2.get-blacklisted-dimensions` | OP | Listing blacklisted dimensions | -| `sh2.import-homes` | OP | Importing from another plugin | - -
+Accepted values are `true`, `false`, `op` and `not-op`. If you run LuckPerms or similar, an explicit grant or deny there still wins. The [full permission list](https://github.com/Blockframe-Studios/SetHomesTwo#permissions) is on GitHub. ## Configuration -Settings live in **`plugins/SetHomesTwo/config.yml`** on your server, written the first time the plugin starts. Edit it in any text editor, save, then **restart the server** - there is no in-game reload command, so changes do not apply until the server comes back up. - -The file is commented throughout, and every message the plugin sends can be rewritten in it. These are the settings most servers actually change: +Settings live in `plugins/SetHomesTwo/config.yml`, written the first time the plugin starts. Edit it, save, then restart the server. The file is commented throughout, and every message the plugin sends can be rewritten in it. The settings most servers change: | Setting | Default | What it does | | --- | --- | --- | @@ -128,57 +86,8 @@ The file is commented throughout, and every message the plugin sends can be rewr | `maxHomeEnabled` | `false` | Turn home limits on. | | `maxHomesType` | `groups` | `singular` for one server-wide limit, `groups` for per-rank limits. | | `openHomeItem` | `compass` | The item players right-click to open the menu. | -| `defaultHomeItem` | `white_wool` | Icon used when a home is created without one. | -| `inventoryTitle` | `Your homes` | Title of the homes menu. | -| `maxHomeNameLength` | `32` | Longest home name allowed. | -Per-rank limits need [LuckPerms](https://luckperms.net/download) and `maxHomesType: groups`. The [full annotated config](https://github.com/Blockframe-Studios/SetHomesTwo#example-config) is on GitHub. - -
-Upgrading? Your existing config.yml will not gain the new settings - -Set Homes Two never touches a `config.yml` that already exists, so settings added in a later release do not appear in a file written by an earlier one. Any missing setting quietly falls back to its default, so nothing breaks - but you cannot change a setting you cannot see. - -To pick one up, copy the key you want out of the [full config](https://github.com/Blockframe-Studios/SetHomesTwo#example-config) into your file and restart. To start clean, rename your `config.yml` and restart - a fresh one is written with everything in it, and you can copy your old values across. - -
- -## FAQ - -
-How do my players teleport home? - -Three ways, all equivalent: `/home `, opening `/homes` and left-clicking, or right-clicking the assigned "Home Item" from `/give-homes-item`. - -
- -
-Only OPs can create homes. How do I let everyone in? - -Update to 1.1.0 or later. On older versions every permission defaulted to OP; they now default to granted for players. If you use a permissions plugin that denies unlisted nodes, grant `sh2.create-home`, `sh2.go-home` and `sh2.teleport`. - -
- -
-How do I give donors more homes than everyone else? - -Install LuckPerms, set `maxHomeEnabled: true` and `maxHomesType: groups`, then run `/set-max-homes ` for each rank. - -
- -
-Can I run it alongside EssentialsX? - -Not comfortably - both register `/sethome`, `/home` and `/delhome`, and whichever loads last wins. Import your homes, then remove EssentialsX. - -
- -
-Where are homes stored? - -In a SQLite database in `plugins/SetHomesTwo/`. Nothing external to install and nothing to configure. - -
+Per-rank limits need [LuckPerms](https://luckperms.net/download). Everything else is in [`default-config.yml`](https://github.com/Blockframe-Studios/SetHomesTwo/blob/master/src/main/resources/default-config.yml), the file your `config.yml` is first written from. ## Requirements @@ -188,6 +97,8 @@ In a SQLite database in `plugins/SetHomesTwo/`. Nothing external to install and ## Support -Found a bug or want a feature? Open an issue on [GitHub](https://github.com/Blockframe-Studios/SetHomesTwo/issues) - it gets seen faster than a comment on this page. +The [README on GitHub](https://github.com/Blockframe-Studios/SetHomesTwo#readme) has the full command and permission lists, the EssentialsX import guide, an FAQ and the changelog. + +Found a bug or want a feature? Open an issue on [GitHub](https://github.com/Blockframe-Studios/SetHomesTwo/issues). It gets seen faster than a comment on this page. [Source](https://github.com/Blockframe-Studios/SetHomesTwo) | [Report a bug](https://github.com/Blockframe-Studios/SetHomesTwo/issues) | [Donate](https://www.paypal.com/donate/?business=8LXCRFX27B37C&no_recurring=0&item_name=Thanks+for+your+support.+It+helps+keep+this+plugin+up+to+date+%3A%29¤cy_code=USD) diff --git a/docs/generated/listing-spigotmc.bbcode b/docs/generated/listing-spigotmc.bbcode index 8290acd..e7237fe 100644 --- a/docs/generated/listing-spigotmc.bbcode +++ b/docs/generated/listing-spigotmc.bbcode @@ -1,20 +1,21 @@ -[IMG]https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/master/docs/img/logo.png[/IMG] +[IMG]https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/dev/docs/img/logo.png[/IMG] -[B]Set Homes Two gives every player a menu of their homes.[/B] Left-click one to teleport. Right-click it to rename it, move it, change its icon, or delete it. +[B]Set Homes gives every player a menu of their homes.[/B] Left-click one to teleport. Right-click it to rename it, move it, change its icon, or delete it. -[URL=https://github.com/Blockframe-Studios/SetHomesTwo]Source[/URL] | [URL=https://github.com/Blockframe-Studios/SetHomesTwo/issues]Report a bug[/URL] | [URL=https://www.paypal.com/donate/?business=8LXCRFX27B37C&no_recurring=0&item_name=Thanks+for+your+support.+It+helps+keep+this+plugin+up+to+date+%3A%29¤cy_code=USD]Donate[/URL] +[URL=https://github.com/Blockframe-Studios/SetHomesTwo#readme]Full documentation[/URL] | [URL=https://github.com/Blockframe-Studios/SetHomesTwo/issues]Report a bug[/URL] | [URL=https://www.paypal.com/donate/?business=8LXCRFX27B37C&no_recurring=0&item_name=Thanks+for+your+support.+It+helps+keep+this+plugin+up+to+date+%3A%29¤cy_code=USD]Donate[/URL] -[IMG]https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/master/docs/img/homes-menu.png[/IMG] +[IMG]https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/dev/docs/img/homes-menu.png[/IMG] -[SIZE=5][B]Why Set Homes Two[/B][/SIZE] +[SIZE=5][B]Why Set Homes[/B][/SIZE] [LIST] [*][B]A menu, or a list of commands.[/B] Homes live in a chest-style GUI. Players open it with /homes or by right-clicking the configured "Home Item". [*][B]Every home gets its own icon.[/B] Pick any Minecraft item when you create a home, or change it later to whatever you are holding. A base, a mine and a farm stop looking identical. [*][B]Rename, move and delete in-game.[/B] Right-click any home to manage it. Deleting always asks first, so nobody loses a base to a misclick. -[*][B]Teleports that do not kill you.[/B] Set Homes Two checks the destination and relocates you to the nearest safe spot rather than dropping you into blocks, lava, or a fall. -[*][B]Switch without losing anything.[/B] One command imports every home from EssentialsX or Set Homes v1, and shows you exactly what it will do before it does it. +[*][B]Teleports that do not kill you.[/B] Set Homes checks the destination and relocates you to the nearest safe spot rather than dropping you into blocks, lava, or a fall. +[*][B]Switch without losing anything.[/B] /import-homes essentialsx brings every EssentialsX home across, and previews exactly what it will do before it does it. Then remove EssentialsX, since both plugins claim /sethome and /home. [*][B]Per-rank home limits.[/B] Give donors more homes than default players with LuckPerms groups, or set one server-wide limit. +[*][B]Permissions you can change from the config.[/B] Every sh2.* node has a sensible default, and any of them can be moved in config.yml. No permissions plugin required. [/LIST] [SIZE=5][B]Quick start[/B][/SIZE] @@ -25,104 +26,63 @@ [*]Run /homes and click it. [/LIST] -That is genuinely the whole setup. Since 1.1.0 the player-facing permissions default to granted, so your players can create and use homes the moment the plugin loads. You only need a permissions plugin if you want per-rank home limits. - -[SIZE=5][B]Managing homes[/B][/SIZE] - -[IMG]https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/master/docs/img/manage-menu.png[/IMG] +That is genuinely the whole setup. Player permissions default to granted, so your players can create and use homes the moment the plugin loads. -Open your homes with /homes, or right-click the homes item. Then: +[SIZE=5][B]Commands[/B][/SIZE] [TABLE] -[TR][TH]Action[/TH][TH]What happens[/TH][/TR] -[TR][TD]Left-click a home[/TD][TD]Teleports you there[/TD][/TR] -[TR][TD]Right-click a home[/TD][TD]Opens the management menu below[/TD][/TR] -[TR][TD]Rename[/TD][TD]Opens an anvil prompt - type the new name[/TD][/TR] -[TR][TD]Move home here[/TD][TD]Repoints the home at where you are standing[/TD][/TR] -[TR][TD]Set icon to held item[/TD][TD]The home's icon becomes whatever you are holding[/TD][/TR] -[TR][TD]Delete[/TD][TD]Asks for confirmation first[/TD][/TR] +[TR][TH]Command[/TH][TH]What it does[/TH][/TR] +[TR][TD]/sethome [name][/TD][TD]Creates a home where you stand.[/TD][/TR] +[TR][TD]/home [name][/TD][TD]Teleports you to a home.[/TD][/TR] +[TR][TD]/homes[/TD][TD]Opens the homes menu.[/TD][/TR] +[TR][TD]/delhome [/TD][TD]Deletes a home.[/TD][/TR] +[TR][TD]/uhome [/TD][TD]Moves one of your homes to where you are standing.[/TD][/TR] [/TABLE] -[IMG]https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/master/docs/img/rename.gif[/IMG] - -Home names are unique per player and ignore case, so base and Base are the same home. Management is controlled by sh2.manage-homes, which defaults to granted. - -Changing a home's icon works the same way - hold the item you want and click [B]Set icon to held item[/B]: +Names are optional on /sethome and /home. Leave the name off and both use a home called default. -[IMG]https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/master/docs/img/change-icon.gif[/IMG] +Admins also get /set-max-homes, /blacklist, /import-homes, and commands to view, visit, move and delete other players' homes. The [URL=https://github.com/Blockframe-Studios/SetHomesTwo#commands]full command list[/URL], with long forms and aliases, is on GitHub. -[SIZE=5][B]Teleporting[/B][/SIZE] +[SIZE=5][B]Managing homes[/B][/SIZE] -[IMG]https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/master/docs/img/teleport-delay.gif[/IMG] +[IMG]https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/dev/docs/img/manage-menu.png[/IMG] -By default players wait three seconds before a teleport fires, and moving cancels it - so a home is not a free escape from a fight. Set delay: 0 for instant teleports, or cancelOnMove: false to let players walk during the countdown. +Open your homes with /homes, or right-click the homes item. Left-click a home to teleport, right-click it to rename it, move it to where you are standing, set its icon to the item you are holding, or delete it (with a confirmation). -[IMG]https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/master/docs/img/teleport-instant.gif[/IMG] +[IMG]https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/dev/docs/img/rename.gif[/IMG] -Before it drops anyone anywhere, Set Homes Two checks the destination is safe to stand in. If a home has been built over, flooded with lava, or left hanging above a drop, the player is moved to the nearest safe spot instead, or the teleport is cancelled and they are told why. Turn it off with teleportSafety: false. +[IMG]https://raw.githubusercontent.com/Blockframe-Studios/SetHomesTwo/dev/docs/img/change-icon.gif[/IMG] -[SIZE=5][B]Coming from EssentialsX or Set Homes v1[/B][/SIZE] +[SIZE=5][B]Teleporting[/B][/SIZE] -Your players keep their homes. The old plugin does not even need to be running - the importer reads its data files directly. +By default players wait three seconds before a teleport fires, and moving cancels it, so a home is not a free escape from a fight. Set delay: 0 for instant teleports, or cancelOnMove: false to let players walk during the countdown. -[LIST] -[*]Run /import-homes essentialsx (or /import-homes sethomes). This is a [B]preview only[/B]. It reports how many homes it would import and warns about any it would skip, and changes nothing. -[*]Happy with the numbers? Run it again with confirm on the end. -[*]Existing homes are never overwritten, so re-running it is always safe. Homes in worlds that no longer exist are skipped with a warning naming the world. -[/LIST] +Before it drops anyone anywhere, Set Homes checks the destination is safe to stand in. If a home has been built over, flooded with lava, or left hanging above a drop, the player is moved to the nearest safe spot instead. Turn it off with teleportSafety: false. -Afterwards, remove the old jar. Set Homes Two provides /sethome, /home and /delhome, and two plugins claiming the same commands will fight over them. +[SIZE=5][B]Permissions[/B][/SIZE] -[SIZE=5][B]Commands[/B][/SIZE] +Nothing here needs a permissions plugin. Out of the box, every player can create, list, teleport to and manage their own homes, and operators get everything else. Two bundles cover it: [TABLE] -[TR][TH]Command[/TH][TH]Long form[/TH][TH]What it does[/TH][/TR] -[TR][TD]/sethome [item] [description][/TD][TD]/create-home[/TD][TD]Creates a home where you stand. The optional item becomes its icon.[/TD][/TR] -[TR][TD]/home [/TD][TD]/go-home[/TD][TD]Teleports you to a home.[/TD][/TR] -[TR][TD]/homes[/TD][TD]-[/TD][TD]Opens the homes menu.[/TD][/TR] -[TR][TD]/delhome [/TD][TD]/delete-home[/TD][TD]Deletes a home.[/TD][/TR] -[TR][TD]/list-homes[/TD][TD]-[/TD][TD]Lists your homes in chat. Click a name to teleport.[/TD][/TR] -[TR][TD]/give-homes-item[/TD][TD]-[/TD][TD]Gives you the item that opens the menu.[/TD][/TR] +[TR][TH]Bundle[/TH][TH]Default[/TH][TH]Contains[/TH][/TR] +[TR][TD]sh2.player[/TD][TD]everyone[/TD][TD]Every node an ordinary player needs[/TD][/TR] +[TR][TD]sh2.admin[/TD][TD]OP[/TD][TD]sh2.player, plus every admin and bypass node[/TD][/TR] [/TABLE] -[SPOILER=Admin commands] -[TABLE] -[TR][TH]Command[/TH][TH]What it does[/TH][/TR] -[TR][TD]/set-max-homes [group] [/TD][TD]Sets the home limit, per LuckPerms group or server-wide.[/TD][/TR] -[TR][TD]/get-player-homes [/TD][TD]Lists another player's homes.[/TD][/TR] -[TR][TD]/add-to-blacklist [/TD][TD]Stops homes being set in a dimension.[/TD][/TR] -[TR][TD]/remove-from-blacklist [/TD][TD]Lifts the restriction again.[/TD][/TR] -[TR][TD]/get-blacklisted-dimensions[/TD][TD]Shows which dimensions are blacklisted.[/TD][/TR] -[TR][TD]/import-homes [confirm][/TD][TD]Imports homes from another plugin. Dry-run unless confirm is given.[/TD][/TR] -[/TABLE] -[/SPOILER] +To change a default without a permissions plugin, uncomment the permissions: block in config.yml: -[SIZE=5][B]Permissions[/B][/SIZE] +[CODE] +permissions: + sh2.manage-homes: false + sh2.get-player-homes: true + sh2.import-homes: op +[/CODE] -[SPOILER=Full permission list] -[TABLE] -[TR][TH]Permission[/TH][TH]Default[/TH][TH]Allows[/TH][/TR] -[TR][TD]sh2.create-home[/TD][TD]everyone[/TD][TD]Creating homes[/TD][/TR] -[TR][TD]sh2.go-home[/TD][TD]everyone[/TD][TD]Using the go-home command[/TD][/TR] -[TR][TD]sh2.teleport[/TD][TD]everyone[/TD][TD]Actually teleporting to a home[/TD][/TR] -[TR][TD]sh2.list-homes[/TD][TD]everyone[/TD][TD]Listing homes in chat, and /homes[/TD][/TR] -[TR][TD]sh2.delete-home[/TD][TD]everyone[/TD][TD]Deleting your own homes[/TD][/TR] -[TR][TD]sh2.give-homes-item[/TD][TD]everyone[/TD][TD]Getting the menu item[/TD][/TR] -[TR][TD]sh2.manage-homes[/TD][TD]everyone[/TD][TD]Renaming, moving, re-iconing and deleting from the GUI[/TD][/TR] -[TR][TD]sh2.set-max-homes[/TD][TD]OP[/TD][TD]Setting home limits[/TD][/TR] -[TR][TD]sh2.get-player-homes[/TD][TD]OP[/TD][TD]Viewing another player's homes[/TD][/TR] -[TR][TD]sh2.add-to-blacklist[/TD][TD]OP[/TD][TD]Blacklisting a dimension[/TD][/TR] -[TR][TD]sh2.remove-from-blacklist[/TD][TD]OP[/TD][TD]Un-blacklisting a dimension[/TD][/TR] -[TR][TD]sh2.get-blacklisted-dimensions[/TD][TD]OP[/TD][TD]Listing blacklisted dimensions[/TD][/TR] -[TR][TD]sh2.import-homes[/TD][TD]OP[/TD][TD]Importing from another plugin[/TD][/TR] -[/TABLE] -[/SPOILER] +Accepted values are true, false, op and not-op. If you run LuckPerms or similar, an explicit grant or deny there still wins. The [URL=https://github.com/Blockframe-Studios/SetHomesTwo#permissions]full permission list[/URL] is on GitHub. [SIZE=5][B]Configuration[/B][/SIZE] -Settings live in [B]plugins/SetHomesTwo/config.yml[/B] on your server, written the first time the plugin starts. Edit it in any text editor, save, then [B]restart the server[/B] - there is no in-game reload command, so changes do not apply until the server comes back up. - -The file is commented throughout, and every message the plugin sends can be rewritten in it. These are the settings most servers actually change: +Settings live in plugins/SetHomesTwo/config.yml, written the first time the plugin starts. Edit it, save, then restart the server. The file is commented throughout, and every message the plugin sends can be rewritten in it. The settings most servers change: [TABLE] [TR][TH]Setting[/TH][TH]Default[/TH][TH]What it does[/TH][/TR] @@ -132,40 +92,9 @@ The file is commented throughout, and every message the plugin sends can be rewr [TR][TD]maxHomeEnabled[/TD][TD]false[/TD][TD]Turn home limits on.[/TD][/TR] [TR][TD]maxHomesType[/TD][TD]groups[/TD][TD]singular for one server-wide limit, groups for per-rank limits.[/TD][/TR] [TR][TD]openHomeItem[/TD][TD]compass[/TD][TD]The item players right-click to open the menu.[/TD][/TR] -[TR][TD]defaultHomeItem[/TD][TD]white_wool[/TD][TD]Icon used when a home is created without one.[/TD][/TR] -[TR][TD]inventoryTitle[/TD][TD]Your homes[/TD][TD]Title of the homes menu.[/TD][/TR] -[TR][TD]maxHomeNameLength[/TD][TD]32[/TD][TD]Longest home name allowed.[/TD][/TR] [/TABLE] -Per-rank limits need [URL=https://luckperms.net/download]LuckPerms[/URL] and maxHomesType: groups. The [URL=https://github.com/Blockframe-Studios/SetHomesTwo#example-config]full annotated config[/URL] is on GitHub. - -[SPOILER=Upgrading? Your existing config.yml will not gain the new settings] -Set Homes Two never touches a config.yml that already exists, so settings added in a later release do not appear in a file written by an earlier one. Any missing setting quietly falls back to its default, so nothing breaks - but you cannot change a setting you cannot see. - -To pick one up, copy the key you want out of the [URL=https://github.com/Blockframe-Studios/SetHomesTwo#example-config]full config[/URL] into your file and restart. To start clean, rename your config.yml and restart - a fresh one is written with everything in it, and you can copy your old values across. -[/SPOILER] - -[SIZE=5][B]FAQ[/B][/SIZE] - -[SPOILER=How do my players teleport home?] -Three ways, all equivalent: /home , opening /homes and left-clicking, or right-clicking the assigned "Home Item" from /give-homes-item. -[/SPOILER] - -[SPOILER=Only OPs can create homes. How do I let everyone in?] -Update to 1.1.0 or later. On older versions every permission defaulted to OP; they now default to granted for players. If you use a permissions plugin that denies unlisted nodes, grant sh2.create-home, sh2.go-home and sh2.teleport. -[/SPOILER] - -[SPOILER=How do I give donors more homes than everyone else?] -Install LuckPerms, set maxHomeEnabled: true and maxHomesType: groups, then run /set-max-homes for each rank. -[/SPOILER] - -[SPOILER=Can I run it alongside EssentialsX?] -Not comfortably - both register /sethome, /home and /delhome, and whichever loads last wins. Import your homes, then remove EssentialsX. -[/SPOILER] - -[SPOILER=Where are homes stored?] -In a SQLite database in plugins/SetHomesTwo/. Nothing external to install and nothing to configure. -[/SPOILER] +Per-rank limits need [URL=https://luckperms.net/download]LuckPerms[/URL]. Everything else is in [URL=https://github.com/Blockframe-Studios/SetHomesTwo/blob/master/src/main/resources/default-config.yml]default-config.yml[/URL], the file your config.yml is first written from. [SIZE=5][B]Requirements[/B][/SIZE] @@ -177,6 +106,8 @@ In a SQLite database in plugins/SetHomesTwo/. Nothing external to install and no [SIZE=5][B]Support[/B][/SIZE] -Found a bug or want a feature? Open an issue on [URL=https://github.com/Blockframe-Studios/SetHomesTwo/issues]GitHub[/URL] - it gets seen faster than a comment on this page. +The [URL=https://github.com/Blockframe-Studios/SetHomesTwo#readme]README on GitHub[/URL] has the full command and permission lists, the EssentialsX import guide, an FAQ and the changelog. + +Found a bug or want a feature? Open an issue on [URL=https://github.com/Blockframe-Studios/SetHomesTwo/issues]GitHub[/URL]. It gets seen faster than a comment on this page. [URL=https://github.com/Blockframe-Studios/SetHomesTwo]Source[/URL] | [URL=https://github.com/Blockframe-Studios/SetHomesTwo/issues]Report a bug[/URL] | [URL=https://www.paypal.com/donate/?business=8LXCRFX27B37C&no_recurring=0&item_name=Thanks+for+your+support.+It+helps+keep+this+plugin+up+to+date+%3A%29¤cy_code=USD]Donate[/URL] diff --git a/docs/img/homes-menu.png b/docs/img/homes-menu.png index e483fa2..c2fe095 100644 Binary files a/docs/img/homes-menu.png and b/docs/img/homes-menu.png differ diff --git a/docs/img/logo.png b/docs/img/logo.png index 5d39ca2..df22b6b 100644 Binary files a/docs/img/logo.png and b/docs/img/logo.png differ diff --git a/docs/img/manage-menu.png b/docs/img/manage-menu.png index 449259d..fd270fc 100644 Binary files a/docs/img/manage-menu.png and b/docs/img/manage-menu.png differ diff --git a/pom.xml b/pom.xml index 6f76d60..a573952 100644 --- a/pom.xml +++ b/pom.xml @@ -38,11 +38,32 @@ false + true + + + + net.wesjd:anvilgui + ** + + + org.bstats:* + ** + + + *:* + META-INF/maven/** + + net.wesjd.anvilgui com.samleighton.sethomestwo.lib.anvilgui + + org.bstats + com.samleighton.sethomestwo.lib.bstats + @@ -123,7 +144,7 @@ org.jetbrains annotations RELEASE - compile + provided net.luckperms @@ -140,5 +161,11 @@ 1.10.13-20260714.043149-1 compile + + org.bstats + bstats-bukkit + 3.2.1 + compile + diff --git a/scripts/close-released-issues.sh b/scripts/close-released-issues.sh new file mode 100644 index 0000000..19ef826 --- /dev/null +++ b/scripts/close-released-issues.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Closes the issues contained in the commit being released. +# +# Reads PREV_TAG (may be empty on a first release) and VERSION from the +# environment. With DRY_RUN=1 it prints what it would close and closes nothing. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./issue-status.sh +source "$SCRIPT_DIR/issue-status.sh" + +VERSION="${VERSION:?VERSION must be set}" +PREV_TAG="${PREV_TAG:-}" +DRY_RUN="${DRY_RUN:-0}" + +if [ -z "$PREV_TAG" ] && [ -n "$(git tag --list)" ]; then + echo "Error: PREV_TAG is empty but the repository already has tags - refusing to scan all of history." >&2 + echo "This usually means the checkout is missing tags or full history. Fix the checkout rather than closing every referenced issue." >&2 + exit 1 +fi + +range="HEAD" +if [ -n "$PREV_TAG" ]; then + range="$PREV_TAG..HEAD" +fi +printf 'Range: %s\n' "$range" + +# Subjects only. A commit body may mention a pull request it did not come from. +prs="$(git log --format=%s "$range" | pr_numbers_from_log)" +if [ -z "$prs" ]; then + echo "No pull requests in the range - nothing to close." + exit 0 +fi + +issues="" +pr_count=0 +pr_failures=0 +while IFS= read -r pr; do + [ -n "$pr" ] || continue + pr_count=$((pr_count + 1)) + if ! body="$(gh pr view "$pr" --json body --jq '.body')"; then + printf 'Warning: could not look up pull request #%s - skipping it.\n' "$pr" >&2 + pr_failures=$((pr_failures + 1)) + continue + fi + refs="$(printf '%s' "$body" | closing_refs)" + [ -n "$refs" ] || continue + issues="$(printf '%s\n%s' "$issues" "$refs")" +done <<< "$prs" + +if [ "$pr_count" -gt 0 ] && [ "$pr_failures" -eq "$pr_count" ]; then + printf 'Error: all %d pull request lookups failed - cannot tell what this release closes.\n' "$pr_count" >&2 + exit 1 +fi + +issues="$(printf '%s' "$issues" | grep -E '^[1-9][0-9]*$' | awk '!seen[$0]++')" +if [ -z "$issues" ]; then + echo "No closing references among those pull requests - nothing to close." + exit 0 +fi + +close_failures=0 +while IFS= read -r issue; do + [ -n "$issue" ] || continue + + state="$(gh issue view "$issue" --json state --jq '.state')" || continue + if [ "$state" != "OPEN" ]; then + printf '#%s is already %s - skipping.\n' "$issue" "$state" + continue + fi + + if [ "$DRY_RUN" = "1" ]; then + printf 'Would close #%s (Released in v%s)\n' "$issue" "$VERSION" + continue + fi + + if gh issue close "$issue" --reason completed --comment "Released in v$VERSION"; then + printf 'Closed #%s\n' "$issue" + else + printf 'Failed to close #%s - close it by hand.\n' "$issue" >&2 + close_failures=$((close_failures + 1)) + fi +done <<< "$issues" + +if [ "$close_failures" -gt 0 ]; then + exit 1 +fi diff --git a/scripts/issue-status.sh b/scripts/issue-status.sh new file mode 100644 index 0000000..6c3be92 --- /dev/null +++ b/scripts/issue-status.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Parses issue and pull request references out of text. +# +# Pure text handling, no network, so the workflows that call it stay thin and +# the parsing is unit tested. + +# GitHub's own closing keyword set. A bare #NN must not close anything: pull +# request bodies here routinely mention issues they do not resolve. +CLOSING_KEYWORDS='close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved' + +# Reads text on stdin, writes the issue numbers it closes to stdout, one per +# line, first appearance order, deduplicated. Always exits 0. +closing_refs() { + grep -oiE "\\b(${CLOSING_KEYWORDS})[[:space:]]*:?[[:space:]]+#[0-9]+" \ + | grep -oE '[0-9]+' \ + | grep -E '^[1-9][0-9]*$' \ + | awk '!seen[$0]++' + return 0 +} + +# Reads `git log --format=%s` output on stdin and writes the pull request +# numbers it contains to stdout, one per line, deduplicated. Subjects only: +# a body may mention a pull request the commit did not come from. +pr_numbers_from_log() { + grep -oE '(Merge pull request #[0-9]+|\(#[0-9]+\))' \ + | grep -oE '[0-9]+' \ + | grep -E '^[1-9][0-9]*$' \ + | awk '!seen[$0]++' + return 0 +} + +# What a push should do, given `open-pr` when the branch already has an open +# pull request and anything else when it does not. Prints the target status +# first, then the states it may advance from, one per line. +# +# A branch under review must not be reported as work in progress: merging the +# base branch in is a push like any other. Neither plan names Ready for release +# or Done, so a push can never pull a shipped issue backwards. +push_plan() { + if [ "${1:-}" = "open-pr" ]; then + printf 'In review\nTodo\nunset\nIn progress\n' + else + printf 'In progress\nTodo\nunset\n' + fi +} diff --git a/scripts/publish-bukkitdev.sh b/scripts/publish-bukkitdev.sh index 6362f70..cc4e62c 100755 --- a/scripts/publish-bukkitdev.sh +++ b/scripts/publish-bukkitdev.sh @@ -7,8 +7,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# 913275 is set-homes-two. Not 312833 - that is Set Homes v1. -PROJECT_ID=913275 +# 312833 is the Set Homes listing, the one v1 servers already watch for updates. +PROJECT_ID=312833 API=https://dev.bukkit.org/api if [ -z "${CURSEFORGE_TOKEN:-}" ]; then @@ -16,6 +16,15 @@ if [ -z "${CURSEFORGE_TOKEN:-}" ]; then exit 1 fi +# This listing carries Set Homes v1, which last shipped 1.3.1, so anything below +# 2.0.0 would reach those servers as an update that reads as a downgrade. +case "$VERSION" in + 0.*|1.*) + echo "$VERSION is below the 2.0.0 floor for this listing, refusing to publish" >&2 + exit 1 + ;; +esac + GAME_VERSIONS=$(curl -sS -H "X-Api-Token: $CURSEFORGE_TOKEN" "$API/game/versions" \ | jq -f "$SCRIPT_DIR/game-versions.jq" \ | jq -c 'map(.id)') @@ -40,7 +49,7 @@ CHANGELOG=$(bash "$SCRIPT_DIR/release.sh" notes --readme README.md --version "$V # backslashes and newlines. METADATA=$(jq -n \ --arg changelog "$CHANGELOG" \ - --arg displayName "SetHomesTwo V$VERSION" \ + --arg displayName "Set Homes V$VERSION" \ --argjson gameVersions "$GAME_VERSIONS" \ '{ changelog: $changelog, @@ -55,7 +64,7 @@ METADATA=$(jq -n \ RESPONSE=$(curl -sS -w '\n%{http_code}' -X POST "$API/projects/$PROJECT_ID/upload-file" \ -H "X-Api-Token: $CURSEFORGE_TOKEN" \ --form-string "metadata=$METADATA" \ - -F "file=@SetHomesTwo.V$VERSION.jar") + -F "file=@SetHomes.V$VERSION.jar") BODY=$(echo "$RESPONSE" | head -n -1) CODE=$(echo "$RESPONSE" | tail -n 1) diff --git a/scripts/require-changeset.sh b/scripts/require-changeset.sh index dbeacc1..9795cc4 100755 --- a/scripts/require-changeset.sh +++ b/scripts/require-changeset.sh @@ -9,6 +9,9 @@ CHANGESET_DIR=".changeset" +# Label that marks a master to dev down-merge. +DOWNMERGE_LABEL="downmerge" + # Nothing under these prefixes ships to a user's server. EXEMPT_PREFIXES=( "$CHANGESET_DIR/" @@ -66,6 +69,18 @@ main() { local line path has_changeset=1 local shippable=() + # Everything on master has already been released, so a down-merge must not + # carry a changeset: the next promotion would consume it and bump the version + # a second time for the same work. + if [ "${PR_BASE:-}" = "dev" ]; then + case ",${PR_LABELS:-}," in + *,"$DOWNMERGE_LABEL",*) + printf 'Labelled %s into dev - no changeset needed.\n' "$DOWNMERGE_LABEL" + return 0 + ;; + esac + fi + while IFS= read -r line || [ -n "$line" ]; do # gh writes LF, but a hand-piped list on Windows may not. path="${line%$'\r'}" @@ -94,6 +109,7 @@ main() { printf 'This pull request changes files that ship to users but adds no changeset:\n' printf ' %s\n' "${shippable[@]}" printf '\nAdd one with: bash scripts/changeset.sh\n' + printf 'Down-merging master into dev? Label the pull request %s instead.\n' "$DOWNMERGE_LABEL" } >&2 return 1 } diff --git a/scripts/set-issue-status.sh b/scripts/set-issue-status.sh new file mode 100644 index 0000000..6150b80 --- /dev/null +++ b/scripts/set-issue-status.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# Sets an issue's Status on the SetHomesTwo project board. +# +# bash scripts/set-issue-status.sh 54 "In review" +# bash scripts/set-issue-status.sh 54 "In progress" Todo unset +# +# The optional trailing arguments guard the write: the status is set only when +# the item currently holds one of them, where `unset` means no status yet. That +# is what stops a later push to an issue branch pulling the issue back out of +# In review. +# +# Needs GH_TOKEN with the project scope; GITHUB_TOKEN cannot reach an +# organization project. +set -uo pipefail + +ORG="Blockframe-Studios" +REPO="SetHomesTwo" +PROJECT_NUMBER="${PROJECT_NUMBER:?PROJECT_NUMBER must be set}" + +ISSUE="${1:?issue number required}" +TARGET_STATUS="${2:?target status required}" +shift 2 +ALLOWED_CURRENT=("$@") + +PROJECT_QUERY=' + query($org:String!, $number:Int!) { + organization(login:$org) { + projectV2(number:$number) { + id + field(name:"Status") { + ... on ProjectV2SingleSelectField { id options { id name } } + } + } + } + }' + +# gh has jq built in. Standalone jq is not installed on the development machine, +# and every line here has to run locally as well as on a runner. +ids="$(gh api graphql -f query="$PROJECT_QUERY" -f org="$ORG" \ + -F number="$PROJECT_NUMBER" \ + --jq '[.data.organization.projectV2.id, + .data.organization.projectV2.field.id] | @tsv')" || exit 1 +IFS=$'\t' read -r project_id field_id <<<"$ids" + +options="$(gh api graphql -f query="$PROJECT_QUERY" -f org="$ORG" \ + -F number="$PROJECT_NUMBER" \ + --jq '.data.organization.projectV2.field.options[] | [.name, .id] | @tsv')" || exit 1 + +# Matched in awk rather than inside the jq program, so the status name is never +# interpolated into a query. +option_id="$(printf '%s\n' "$options" \ + | awk -F'\t' -v n="$TARGET_STATUS" '$1 == n { print $2; exit }')" + +if [ -z "$option_id" ]; then + printf 'No Status option named [%s] on the project.\n' "$TARGET_STATUS" >&2 + exit 1 +fi + +ISSUE_QUERY=' + query($owner:String!, $repo:String!, $issue:Int!) { + repository(owner:$owner, name:$repo) { + issue(number:$issue) { + id + projectItems(first:20) { + nodes { + id + project { id } + fieldValueByName(name:"Status") { + ... on ProjectV2ItemFieldSingleSelectValue { name } + } + } + } + } + } + }' + +issue_id="$(gh api graphql -f query="$ISSUE_QUERY" -f owner="$ORG" -f repo="$REPO" \ + -F issue="$ISSUE" --jq '.data.repository.issue.id')" || exit 1 +if [ -z "$issue_id" ]; then + printf 'Issue #%s not found - nothing to do.\n' "$ISSUE" + exit 0 +fi + +items="$(gh api graphql -f query="$ISSUE_QUERY" -f owner="$ORG" -f repo="$REPO" \ + -F issue="$ISSUE" \ + --jq '.data.repository.issue.projectItems.nodes[] + | [.project.id, .id, (.fieldValueByName.name // "unset")] | @tsv')" || exit 1 + +item_id="$(printf '%s\n' "$items" \ + | awk -F'\t' -v p="$project_id" '$1 == p { print $2; exit }')" +current_status="$(printf '%s\n' "$items" \ + | awk -F'\t' -v p="$project_id" '$1 == p { print $3; exit }')" + +if [ -z "$item_id" ] || [ "$item_id" = "null" ]; then + current_status="unset" + # Idempotent: returns the existing item when the issue is already on the board. + item_id="$(gh api graphql -f query=' + mutation($project:ID!, $content:ID!) { + addProjectV2ItemById(input:{projectId:$project, contentId:$content}) { + item { id } + } + }' -f project="$project_id" -f content="$issue_id" \ + --jq '.data.addProjectV2ItemById.item.id')" || exit 1 +fi + +if [ "${#ALLOWED_CURRENT[@]}" -gt 0 ]; then + allowed=1 + for candidate in "${ALLOWED_CURRENT[@]}"; do + if [ "$candidate" = "$current_status" ]; then + allowed=0 + break + fi + done + if [ "$allowed" -ne 0 ]; then + printf '#%s is [%s], not one of [%s] - leaving it alone.\n' \ + "$ISSUE" "$current_status" "${ALLOWED_CURRENT[*]}" + exit 0 + fi +fi + +gh api graphql -f query=' + mutation($project:ID!, $item:ID!, $field:ID!, $option:String!) { + updateProjectV2ItemFieldValue(input:{ + projectId:$project, itemId:$item, fieldId:$field, + value:{ singleSelectOptionId:$option } + }) { projectV2Item { id } } + }' -f project="$project_id" -f item="$item_id" -f field="$field_id" \ + -f option="$option_id" > /dev/null || exit 1 + +printf '#%s: %s -> %s\n' "$ISSUE" "$current_status" "$TARGET_STATUS" diff --git a/scripts/test-issue-status.sh b/scripts/test-issue-status.sh new file mode 100644 index 0000000..51dfb47 --- /dev/null +++ b/scripts/test-issue-status.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +# Defines the issue-status.sh contract. +# +# Sources issue-status.sh so the tests can call its functions in-process. +# +# Run with: bash scripts/test-issue-status.sh +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ISSUE_STATUS_SH="$SCRIPT_DIR/issue-status.sh" + +# shellcheck source=./issue-status.sh +source "$ISSUE_STATUS_SH" + +PASS=0 +FAIL=0 + +pass() { + PASS=$((PASS + 1)) + printf 'ok - %s\n' "$1" +} + +fail() { + FAIL=$((FAIL + 1)) + printf 'FAIL - %s\n' "$1" + if [ -n "${2:-}" ]; then + printf ' %s\n' "$2" + fi +} + +assert_equals() { + local desc="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + pass "$desc" + else + fail "$desc" "expected [$expected] got [$actual]" + fi +} + +# Runs closing_refs over a body and joins the result with commas, so an +# expectation reads as one string. +refs_of() { + printf '%s' "$1" | closing_refs | paste -sd, - +} + +# -- which references close an issue -- + +test_each_keyword_matches() { + assert_equals "close" "1" "$(refs_of 'close #1')" + assert_equals "closes" "2" "$(refs_of 'closes #2')" + assert_equals "closed" "3" "$(refs_of 'closed #3')" + assert_equals "fix" "4" "$(refs_of 'fix #4')" + assert_equals "fixes" "5" "$(refs_of 'fixes #5')" + assert_equals "fixed" "6" "$(refs_of 'fixed #6')" + assert_equals "resolve" "7" "$(refs_of 'resolve #7')" + assert_equals "resolves" "8" "$(refs_of 'resolves #8')" + assert_equals "resolved" "9" "$(refs_of 'resolved #9')" +} + +test_keywords_are_case_insensitive() { + assert_equals "Closes" "10" "$(refs_of 'Closes #10')" + assert_equals "FIXES" "11" "$(refs_of 'FIXES #11')" +} + +test_a_colon_and_extra_space_are_tolerated() { + assert_equals "colon form" "12" "$(refs_of 'Closes: #12')" + assert_equals "wide space" "13" "$(refs_of 'Closes #13')" +} + +test_trailing_punctuation_is_not_part_of_the_number() { + assert_equals "full stop" "53" "$(refs_of 'Closes #53.')" + assert_equals "comma" "53" "$(refs_of 'Closes #53, and more')" +} + +test_a_bare_mention_does_not_close() { + assert_equals "bare hash" "" "$(refs_of 'See #41 for background')" +} + +test_a_keyword_inside_a_word_does_not_count() { + assert_equals "supercloses" "" "$(refs_of 'supercloses #5')" +} + +test_a_real_pull_request_body_yields_only_the_closed_issue() { + local body + body='Closes #53. First of the five sub-issues split out of #41. + +The alternatives are recorded on #41 and were rejected. + +- #46 (found during the in game check) +- The README change slightly overlaps #56.' + assert_equals "PR 58 body closes only 53" "53" "$(refs_of "$body")" +} + +test_several_references_are_all_returned() { + assert_equals "two keywords" "1,2" "$(refs_of 'Closes #1 and fixes #2')" +} + +test_a_repeated_reference_is_returned_once() { + assert_equals "deduped" "7" "$(refs_of 'Closes #7. Also closes #7.')" +} + +test_a_body_with_no_reference_is_empty_and_clean() { + assert_equals "no refs" "" "$(refs_of 'Just a description.')" + printf '%s' 'Just a description.' | closing_refs > /dev/null + assert_equals "exit status" "0" "$?" +} + +test_an_empty_body_is_empty_and_clean() { + assert_equals "empty" "" "$(refs_of '')" + printf '%s' '' | closing_refs > /dev/null + assert_equals "exit status" "0" "$?" +} + +test_issue_zero_is_rejected() { + assert_equals "hash zero" "" "$(refs_of 'Closes #0')" +} + +test_a_non_numeric_reference_is_rejected() { + assert_equals "hash word" "" "$(refs_of 'Closes #abc')" +} + +# Runs pr_numbers_from_log over log subjects and joins the result with commas. +prs_of() { + printf '%s' "$1" | pr_numbers_from_log | paste -sd, - +} + +# -- which pull requests reached a commit range -- + +test_a_merge_subject_yields_its_number() { + assert_equals "merge commit" "58" \ + "$(prs_of 'Merge pull request #58 from Blockframe-Studios/issue-53-refuse-alongside-v1')" +} + +test_a_squash_subject_yields_its_number() { + assert_equals "squash commit" "44" \ + "$(prs_of 'Tab completion matches anywhere in a name (#44)')" +} + +test_a_release_commit_yields_nothing() { + assert_equals "release commit" "" "$(prs_of 'chore(release): 1.2.3')" +} + +test_an_ordinary_commit_yields_nothing() { + assert_equals "plain commit" "" "$(prs_of 'fix: send the BukkitDev metadata with --form-string')" +} + +# A subject may cite an issue without the commit having come from that pull +# request. Only the merge and squash forms count. +test_a_mention_in_a_subject_is_not_a_pull_request() { + assert_equals "bare mention" "" "$(prs_of 'fix: address feedback on #41')" +} + +test_an_empty_range_is_empty_and_clean() { + assert_equals "empty range" "" "$(prs_of '')" + printf '%s' '' | pr_numbers_from_log > /dev/null + assert_equals "exit status" "0" "$?" +} + +test_repeated_numbers_collapse() { + local log + log='Merge pull request #31 from Blockframe-Studios/fix/bukkitdev-metadata-semicolon +Merge pull request #31 from Blockframe-Studios/fix/bukkitdev-metadata-semicolon' + assert_equals "deduped" "31" "$(prs_of "$log")" +} + +# Joins a push plan with commas so an expectation reads as one string. +plan_of() { + push_plan "$1" | paste -sd, - +} + +# -- what a push should aim for -- + +test_a_branch_under_review_aims_for_in_review() { + assert_equals "target under review" "In review" "$(push_plan open-pr | head -1)" +} + +test_a_branch_under_review_may_advance_from_in_progress() { + assert_equals "allowed under review" "In review,Todo,unset,In progress" \ + "$(plan_of open-pr)" +} + +test_a_branch_with_no_pull_request_aims_for_in_progress() { + assert_equals "target with no pull request" "In progress" \ + "$(push_plan none | head -1)" +} + +test_a_branch_with_no_pull_request_cannot_leave_in_progress() { + assert_equals "allowed with no pull request" "In progress,Todo,unset" \ + "$(plan_of none)" +} + +# Neither plan lists Ready for release or Done, so a push can never pull an +# issue back out of either. +test_neither_plan_can_disturb_a_shipped_issue() { + local plan + for arg in open-pr none; do + plan="$(plan_of "$arg")" + # Asserted first, so an empty plan cannot pass this test by matching nothing. + if [ -z "$plan" ]; then + fail "$arg plan is empty" + continue + fi + case "$plan" in + *"Ready for release"*|*Done*) fail "$arg plan must not list a shipped state" ;; + *) pass "$arg plan leaves shipped states alone" ;; + esac + done +} + +test_several_merges_keep_first_appearance_order() { + local log + log='Merge pull request #58 from Blockframe-Studios/issue-53-refuse-alongside-v1 +Merge pull request #52 from Blockframe-Studios/issue-49-import-report-colour-codes' + assert_equals "order kept" "58,52" "$(prs_of "$log")" +} + +test_each_keyword_matches +test_keywords_are_case_insensitive +test_a_colon_and_extra_space_are_tolerated +test_trailing_punctuation_is_not_part_of_the_number +test_a_bare_mention_does_not_close +test_a_keyword_inside_a_word_does_not_count +test_a_real_pull_request_body_yields_only_the_closed_issue +test_several_references_are_all_returned +test_a_repeated_reference_is_returned_once +test_a_body_with_no_reference_is_empty_and_clean +test_an_empty_body_is_empty_and_clean +test_issue_zero_is_rejected +test_a_non_numeric_reference_is_rejected +test_a_merge_subject_yields_its_number +test_a_squash_subject_yields_its_number +test_a_release_commit_yields_nothing +test_an_ordinary_commit_yields_nothing +test_a_mention_in_a_subject_is_not_a_pull_request +test_an_empty_range_is_empty_and_clean +test_repeated_numbers_collapse +test_several_merges_keep_first_appearance_order + +test_a_branch_under_review_aims_for_in_review +test_a_branch_under_review_may_advance_from_in_progress +test_a_branch_with_no_pull_request_aims_for_in_progress +test_a_branch_with_no_pull_request_cannot_leave_in_progress +test_neither_plan_can_disturb_a_shipped_issue + +printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/scripts/test-publish-bukkitdev.sh b/scripts/test-publish-bukkitdev.sh index 15d19d5..40ef9d1 100644 --- a/scripts/test-publish-bukkitdev.sh +++ b/scripts/test-publish-bukkitdev.sh @@ -163,17 +163,37 @@ else fi DISPLAY_NAME=$(printf '%s' "$METADATA" | jq -r '.displayName // ""' 2>/dev/null) -if [ "$DISPLAY_NAME" = "SetHomesTwo V9.9.9" ]; then +if [ "$DISPLAY_NAME" = "Set Homes V9.9.9" ]; then pass "names the file after the version" else fail "names the file after the version" "got: $DISPLAY_NAME" fi -if has_arg "file=@SetHomesTwo.V9.9.9.jar"; then +if has_arg "file=@SetHomes.V9.9.9.jar"; then pass "uploads the versioned jar" else fail "uploads the versioned jar" "args were: $(args_summary)" fi +# The listing this publishes to carries Set Homes v1, which shipped 1.3.1, so a +# 1.x upload would be offered to those servers as an update. The guard runs +# before the first API call, so a refused run records no curl at all. +REFUSE_ARGS_DIR="$WORK/args-refused" +REFUSE_OUTPUT=$(cd "$WORK/run" && PATH="$WORK/bin:$PATH" CURSEFORGE_TOKEN=test-token \ + VERSION=1.2.3 CURL_ARGS_DIR="$REFUSE_ARGS_DIR" bash "$PUBLISH_SH" 2>&1) +REFUSE_STATUS=$? + +if [ "$REFUSE_STATUS" -ne 0 ]; then + pass "refuses to publish a version below 2.0.0" +else + fail "refuses to publish a version below 2.0.0" "exited 0: $REFUSE_OUTPUT" +fi + +if [ ! -d "$REFUSE_ARGS_DIR" ]; then + pass "calls nothing when it refuses" +else + fail "calls nothing when it refuses" "recorded: $(ls "$REFUSE_ARGS_DIR")" +fi + printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" [ "$FAIL" -eq 0 ] diff --git a/scripts/test-require-changeset.sh b/scripts/test-require-changeset.sh index 5336a7a..fb30821 100755 --- a/scripts/test-require-changeset.sh +++ b/scripts/test-require-changeset.sh @@ -185,6 +185,38 @@ test_blank_lines_are_ignored() { "$(printf 'README.md\n\n' | bash "$REQUIRE_SH" > /dev/null 2>&1; printf '%s' "$?")" } +# -- down-merge bypass -- + +# Runs the script the way the workflow does for a pull request, with the base +# branch and labels supplied. +run_cli_pr() { + local base="$1" labels="$2" + shift 2 + printf '%s +' "$@" | PR_BASE="$base" PR_LABELS="$labels" bash "$REQUIRE_SH" > /dev/null 2>&1 + printf '%s' "$?" +} + +test_a_labelled_downmerge_into_dev_passes() { + assert_status "a downmerge-labelled pull request into dev needs no changeset" 0 "$(run_cli_pr dev downmerge "src/main/java/Foo.java" "pom.xml")" +} + +test_the_label_is_found_among_others() { + assert_status "the label is found alongside other labels" 0 "$(run_cli_pr dev "bug,downmerge,java" "src/main/java/Foo.java")" +} + +test_an_unlabelled_pull_request_into_dev_still_fails() { + assert_status "an unlabelled pull request into dev still needs a changeset" 1 "$(run_cli_pr dev "" "src/main/java/Foo.java")" +} + +test_the_label_does_not_bypass_a_release() { + assert_status "the label does not bypass a pull request into master" 1 "$(run_cli_pr master downmerge "src/main/java/Foo.java")" +} + +test_the_label_is_matched_whole() { + assert_status "a label merely containing the word does not bypass" 1 "$(run_cli_pr dev "not-downmerge" "src/main/java/Foo.java")" +} + test_plugin_source_requires_a_changeset test_the_pom_requires_a_changeset test_an_unrecognised_path_requires_a_changeset @@ -209,6 +241,12 @@ test_the_failure_explains_the_fix test_carriage_returns_are_tolerated test_blank_lines_are_ignored +test_a_labelled_downmerge_into_dev_passes +test_the_label_is_found_among_others +test_an_unlabelled_pull_request_into_dev_still_fails +test_the_label_does_not_bypass_a_release +test_the_label_is_matched_whole + printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" if [ "$FAIL" -gt 0 ]; then exit 1 diff --git a/src/main/java/com/samleighton/sethomestwo/SetHomesTwo.java b/src/main/java/com/samleighton/sethomestwo/SetHomesTwo.java index f1e3d30..dc4ae3e 100644 --- a/src/main/java/com/samleighton/sethomestwo/SetHomesTwo.java +++ b/src/main/java/com/samleighton/sethomestwo/SetHomesTwo.java @@ -4,21 +4,27 @@ import com.samleighton.sethomestwo.connections.ConnectionManager; import com.samleighton.sethomestwo.dao.TeleportAttemptsDao; import com.samleighton.sethomestwo.enums.DebugLevel; +import com.samleighton.sethomestwo.metrics.CommandUsageListener; +import com.samleighton.sethomestwo.metrics.MetricsReporter; +import com.samleighton.sethomestwo.metrics.UsageCounters; import com.samleighton.sethomestwo.events.PlayerJoin; import com.samleighton.sethomestwo.events.PlayerLeave; import com.samleighton.sethomestwo.events.PlayerMoveWhileTeleporting; import com.samleighton.sethomestwo.events.RightClickHomeItem; import com.samleighton.sethomestwo.gui.GuiSession; import com.samleighton.sethomestwo.gui.HomesGui; +import com.samleighton.sethomestwo.importers.PendingV1Import; import com.samleighton.sethomestwo.models.TeleportAttempt; import com.samleighton.sethomestwo.updates.GitHubReleaseSource; import com.samleighton.sethomestwo.updates.UpdateChecker; -import com.samleighton.sethomestwo.tabcompleters.DimensionTabCompleter; +import com.samleighton.sethomestwo.tabcompleters.BlacklistTabCompleter; import com.samleighton.sethomestwo.tabcompleters.HomesTabCompleter; +import com.samleighton.sethomestwo.tabcompleters.ImportSourcesTabCompleter; import com.samleighton.sethomestwo.tabcompleters.MaterialsTabCompleter; -import com.samleighton.sethomestwo.tabcompleters.RemoveDimensionTabCompleter; +import com.samleighton.sethomestwo.tabcompleters.PlayerHomesTabCompleter; import com.samleighton.sethomestwo.utils.ConfigUtil; import com.samleighton.sethomestwo.utils.DatabaseUtil; +import com.samleighton.sethomestwo.utils.PermissionOverrides; import org.bukkit.Bukkit; import org.bukkit.command.PluginCommand; import org.bukkit.entity.Player; @@ -33,11 +39,17 @@ import java.util.Map; import java.util.Objects; import java.util.UUID; +import java.util.logging.Logger; public class SetHomesTwo extends JavaPlugin { + // Set Homes v1's plugin.yml name. Ours differs, so Bukkit loads both happily. + private static final String SET_HOMES_V1 = "SetHomes"; + private final ConnectionManager connectionManager = new ConnectionManager(); private final Map guiSessionMap = new HashMap<>(); + private final UsageCounters usageCounters = new UsageCounters(); private UpdateChecker updateChecker; + private MetricsReporter metricsReporter; /** * Looked up by name because JavaPlugin.getPlugin(SetHomesTwo.class) needs the @@ -49,12 +61,21 @@ public static SetHomesTwo instance() { @Override public void onEnable() { + // Before anything touches disk, so a refused boot leaves the server as it was. + if (Bukkit.getPluginManager().getPlugin(SET_HOMES_V1) != null) { + refuseToRunAlongsideV1(); + return; + } + // Create the directories for the plugin createDirectories(); // Create config initConfig(); + // Needs the config on disk, so it cannot move above initConfig. + PermissionOverrides.apply(); + // Built before the listeners: the join listener is handed this instance. updateChecker = new UpdateChecker( this, @@ -63,6 +84,9 @@ public void onEnable() { ); updateChecker.checkLater(); + metricsReporter = new MetricsReporter(this); + metricsReporter.startLater(); + // Plugin startup logic registerCommands(); registerEventListeners(); @@ -94,6 +118,57 @@ public void onEnable() { } else { Bukkit.getLogger().severe("Could not create database connection!"); } + + // Last, because it asks the database whether anything has been imported. + announcePendingV1Import(); + } + + /** + * Says so when v1 homes are sitting there unimported. Silent once any home + * exists here, so it needs no marker file. + */ + private void announcePendingV1Import() { + int waiting = PendingV1Import.waitingToBeImported(); + if (waiting == 0) return; + + Logger log = Bukkit.getLogger(); + log.warning("============================================================"); + log.warning("Set Homes found " + waiting + " home(s) in " + PendingV1Import.SOURCE_PATH); + log.warning("and none of its own, so your players cannot see theirs yet."); + log.warning(""); + log.warning("Run /import-homes sethomes for a preview that changes"); + log.warning("nothing, then /import-homes sethomes confirm to bring"); + log.warning("them across."); + log.warning("============================================================"); + } + + /** + * Logs why we are not starting and disables us. The text is hardcoded because + * this runs before initConfig, so there is no config to override it from. + */ + private void refuseToRunAlongsideV1() { + Logger log = Bukkit.getLogger(); + log.severe("============================================================"); + log.severe("Set Homes v2 did not start: Set Homes v1 is installed too."); + log.severe(""); + log.severe("Both plugins claim /sethome, /home and /delhome, and v1 takes"); + log.severe("them whatever the load order. Left alone, your players' homes"); + log.severe("would be split between the two plugins with nothing to show"); + log.severe("for it in the logs."); + log.severe(""); + log.severe("To finish the upgrade:"); + log.severe(" 1. Stop the server."); + log.severe(" 2. Move the old SetHomes jar file out of plugins/ and keep it"); + log.severe(" until you have migrated to v2."); + log.severe(" It is how you roll back if you change your mind."); + log.severe(" 3. Leave plugins/SetHomes/ folder where it is. Nothing ever"); + log.severe(" writes to it, but it is needed for migrating homes to v2."); + log.severe(" 4. Start the server, then run /import-homes sethomes."); + log.severe(""); + log.severe("Set Homes v1 is still running, exactly as it was."); + log.severe("============================================================"); + + getServer().getPluginManager().disablePlugin(this); } @Override @@ -113,6 +188,8 @@ public void onDisable() { player.removePotionEffect(PotionEffectType.NAUSEA); } + if (metricsReporter != null) metricsReporter.shutdown(); + // Close database connections connectionManager.closeConnections(); @@ -173,16 +250,9 @@ public void registerCommands() { deleteHome.setExecutor(new DeleteHome()); deleteHome.setTabCompleter(new HomesTabCompleter()); - PluginCommand addToBlacklist = Objects.requireNonNull(this.getCommand("add-to-blacklist")); - addToBlacklist.setExecutor(new AddDimensionToBlacklist()); - addToBlacklist.setTabCompleter(new DimensionTabCompleter()); - - PluginCommand removeFromBlacklist = Objects.requireNonNull(this.getCommand("remove-from-blacklist")); - removeFromBlacklist.setExecutor(new RemoveDimensionFromBlacklist()); - removeFromBlacklist.setTabCompleter(new RemoveDimensionTabCompleter()); - - PluginCommand getBlacklistedDimensions = Objects.requireNonNull(this.getCommand("get-blacklisted-dimensions")); - getBlacklistedDimensions.setExecutor(new GetBlacklistedDimensions()); + PluginCommand blacklist = Objects.requireNonNull(this.getCommand("blacklist")); + blacklist.setExecutor(new Blacklist()); + blacklist.setTabCompleter(new BlacklistTabCompleter()); PluginCommand getPlayerHomes = Objects.requireNonNull(this.getCommand("get-player-homes")); getPlayerHomes.setExecutor(new GetPlayerHomes(this)); @@ -192,6 +262,23 @@ public void registerCommands() { PluginCommand importHomes = Objects.requireNonNull(this.getCommand("import-homes")); importHomes.setExecutor(new ImportHomes()); + importHomes.setTabCompleter(new ImportSourcesTabCompleter()); + + PluginCommand moveHome = Objects.requireNonNull(this.getCommand("move-home")); + moveHome.setExecutor(new MoveHome()); + moveHome.setTabCompleter(new HomesTabCompleter()); + + PluginCommand goPlayerHome = Objects.requireNonNull(this.getCommand("go-player-home")); + goPlayerHome.setExecutor(new GoPlayerHome()); + goPlayerHome.setTabCompleter(new PlayerHomesTabCompleter()); + + PluginCommand deletePlayerHome = Objects.requireNonNull(this.getCommand("delete-player-home")); + deletePlayerHome.setExecutor(new DeletePlayerHome()); + deletePlayerHome.setTabCompleter(new PlayerHomesTabCompleter()); + + PluginCommand movePlayerHome = Objects.requireNonNull(this.getCommand("move-player-home")); + movePlayerHome.setExecutor(new MovePlayerHome()); + movePlayerHome.setTabCompleter(new PlayerHomesTabCompleter()); } /** @@ -202,6 +289,7 @@ public void registerEventListeners() { getServer().getPluginManager().registerEvents(new PlayerLeave(this), this); getServer().getPluginManager().registerEvents(new RightClickHomeItem(this), this); getServer().getPluginManager().registerEvents(new PlayerMoveWhileTeleporting(), this); + getServer().getPluginManager().registerEvents(new CommandUsageListener(this), this); } /** @@ -236,7 +324,15 @@ public UpdateChecker getUpdateChecker() { return this.updateChecker; } + public MetricsReporter getMetricsReporter() { + return this.metricsReporter; + } + public Map getGuiSessionMap() { return this.guiSessionMap; } + + public UsageCounters getUsageCounters() { + return this.usageCounters; + } } diff --git a/src/main/java/com/samleighton/sethomestwo/commands/AddDimensionToBlacklist.java b/src/main/java/com/samleighton/sethomestwo/commands/AddDimensionToBlacklist.java deleted file mode 100644 index 9a0a86a..0000000 --- a/src/main/java/com/samleighton/sethomestwo/commands/AddDimensionToBlacklist.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.samleighton.sethomestwo.commands; - -import com.samleighton.sethomestwo.dao.BlacklistDao; -import com.samleighton.sethomestwo.dao.Dao; -import com.samleighton.sethomestwo.enums.DebugLevel; -import com.samleighton.sethomestwo.enums.UserError; -import com.samleighton.sethomestwo.enums.UserInfo; -import com.samleighton.sethomestwo.enums.UserSuccess; -import com.samleighton.sethomestwo.utils.ChatUtils; -import com.samleighton.sethomestwo.utils.ConfigUtil; -import com.samleighton.sethomestwo.utils.ServerUtil; -import org.bukkit.Bukkit; -import org.bukkit.command.Command; -import org.bukkit.command.CommandExecutor; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; -import org.jetbrains.annotations.NotNull; - -import java.util.List; - -public class AddDimensionToBlacklist implements CommandExecutor { - - public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String s, String[] args) { - if (!(commandSender instanceof Player)) { - commandSender.sendMessage(UserError.PLAYERS_ONLY.getValue()); - return false; - } - - Player player = (Player) commandSender; - - // Permission guard - if(!player.hasPermission("sh2.add-to-blacklist")){ - ChatUtils.invalidPermissions(player); - return true; - } - - // Args length guard - if (args.length < 1) { - ChatUtils.incorrectNumArguments(player); - ChatUtils.sendInfo(player, UserInfo.ADD_TO_BLACKLIST_USAGE.getValue()); - return true; - } - - Dao blacklistDao = new BlacklistDao(); - List blacklistedDimensions = blacklistDao.getAll(); - - for (String dimension : args) { - if (!ServerUtil.getValidDimensions().contains(dimension)) { - ChatUtils.sendError(player, String.format(UserError.INVALID_DIMENSION.getValue(), dimension)); - continue; - } - - if(blacklistedDimensions.contains(dimension)) { - ChatUtils.sendError(player, String.format(UserError.DIMENSION_ALREADY_BLACKLISTED.getValue(), dimension)); - continue; - } - - boolean success = blacklistDao.save(dimension); - // Successful addition of blacklist guard - if (!success && ConfigUtil.getDebugLevel().equals(DebugLevel.INFO)) { - Bukkit.getLogger().info(String.format("Failed to add dimension to blacklist. %s", dimension)); - } - - ChatUtils.sendSuccess(player, String.format(UserSuccess.DIMENSION_ADDED_TO_BLACKLIST.getValue(), dimension)); - } - - return true; - } -} diff --git a/src/main/java/com/samleighton/sethomestwo/commands/Blacklist.java b/src/main/java/com/samleighton/sethomestwo/commands/Blacklist.java new file mode 100644 index 0000000..6be8ea9 --- /dev/null +++ b/src/main/java/com/samleighton/sethomestwo/commands/Blacklist.java @@ -0,0 +1,211 @@ +package com.samleighton.sethomestwo.commands; + +import com.samleighton.sethomestwo.dao.BlacklistDao; +import com.samleighton.sethomestwo.dao.Dao; +import com.samleighton.sethomestwo.enums.PluginError; +import com.samleighton.sethomestwo.enums.UserError; +import com.samleighton.sethomestwo.enums.UserInfo; +import com.samleighton.sethomestwo.enums.UserSuccess; +import com.samleighton.sethomestwo.utils.ChatUtils; +import com.samleighton.sethomestwo.utils.ConfigUtil; +import com.samleighton.sethomestwo.utils.ServerUtil; +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +import java.util.Arrays; +import java.util.List; + +/** + * Folds add-to-blacklist, remove-from-blacklist, and get-blacklisted-dimensions + * into one executor. Bukkit only hands onCommand the label the player actually + * typed (add-to-blacklist, remove-from-blacklist, get-blacklisted-dimensions, or + * blacklist), so the subcommand is inferred from that label rather than always + * reading args[0]. Reading args[0] unconditionally would break the bare + * "/add-to-blacklist world_nether" form, which takes no subcommand. + */ +public class Blacklist implements CommandExecutor { + + private static final List SUBCOMMANDS = Arrays.asList("add", "remove", "list"); + + @Override + public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String label, String[] args) { + if (!(commandSender instanceof Player)) { + commandSender.sendMessage(UserError.PLAYERS_ONLY.getValue()); + return true; + } + + Player player = (Player) commandSender; + + String subcommand; + String[] names; + + switch (label.toLowerCase()) { + case "add-to-blacklist": + if (isExplicitSubcommand(args)) { + subcommand = args[0].toLowerCase(); + names = Arrays.copyOfRange(args, 1, args.length); + } else { + subcommand = "add"; + names = args; + } + break; + case "remove-from-blacklist": + if (isExplicitSubcommand(args)) { + subcommand = args[0].toLowerCase(); + names = Arrays.copyOfRange(args, 1, args.length); + } else { + subcommand = "remove"; + names = args; + } + break; + case "get-blacklisted-dimensions": + subcommand = "list"; + names = args; + break; + default: + if (args.length < 1) { + ChatUtils.incorrectNumArguments(player); + ChatUtils.sendInfo(player, String.format(UserInfo.BLACKLIST_USAGE.getValue(), label)); + return true; + } + subcommand = args[0].toLowerCase(); + names = Arrays.copyOfRange(args, 1, args.length); + break; + } + + switch (subcommand) { + case "add": + return add(player, label, names); + case "remove": + return remove(player, label, names); + case "list": + return list(player, label, names); + default: + ChatUtils.sendInfo(player, String.format(UserInfo.BLACKLIST_USAGE.getValue(), label)); + return true; + } + } + + /** + * True when the alias's first argument is itself one of add/remove/list, so + * "/add-to-blacklist add world_nether" is honoured instead of blacklisting a + * world named "add". + */ + private boolean isExplicitSubcommand(String[] args) { + return args.length >= 1 && SUBCOMMANDS.contains(args[0].toLowerCase()); + } + + private boolean add(Player player, String label, String[] dimensions) { + if (!player.hasPermission("sh2.add-to-blacklist")) { + ChatUtils.invalidPermissions(player); + return true; + } + + if (dimensions.length < 1) { + ChatUtils.incorrectNumArguments(player); + ChatUtils.sendInfo(player, String.format(UserInfo.BLACKLIST_USAGE.getValue(), label)); + return true; + } + + Dao blacklistDao = new BlacklistDao(); + List blacklistedDimensions = blacklistDao.getAll(); + + for (String dimension : dimensions) { + if (!ServerUtil.getValidDimensions().contains(dimension)) { + ChatUtils.sendError(player, String.format( + ConfigUtil.getConfig().getString("invalidWorld", UserError.INVALID_WORLD.getValue()), + dimension, String.join(", ", ServerUtil.getValidDimensions()))); + continue; + } + + if (blacklistedDimensions.contains(dimension)) { + ChatUtils.sendError(player, String.format(UserError.DIMENSION_ALREADY_BLACKLISTED.getValue(), dimension)); + continue; + } + + if (!blacklistDao.save(dimension)) { + Bukkit.getLogger().severe(String.format("Failed to add dimension to blacklist. %s", dimension)); + ChatUtils.sendError(player, PluginError.ADD_DIMENSION_FAILED.getValue()); + continue; + } + + ChatUtils.sendSuccess(player, String.format( + ConfigUtil.getConfig().getString("dimensionAddedToBlacklist", UserSuccess.DIMENSION_ADDED_TO_BLACKLIST.getValue()), + dimension)); + } + + return true; + } + + private boolean remove(Player player, String label, String[] dimensions) { + if (!player.hasPermission("sh2.remove-from-blacklist")) { + ChatUtils.invalidPermissions(player); + return true; + } + + if (dimensions.length < 1) { + ChatUtils.incorrectNumArguments(player); + ChatUtils.sendInfo(player, String.format(UserInfo.BLACKLIST_USAGE.getValue(), label)); + return true; + } + + Dao blacklistDao = new BlacklistDao(); + List blacklistedDimensions = blacklistDao.getAll(); + + for (String dimension : dimensions) { + if (!ServerUtil.getValidDimensions().contains(dimension)) { + ChatUtils.sendError(player, String.format( + ConfigUtil.getConfig().getString("invalidWorld", UserError.INVALID_WORLD.getValue()), + dimension, String.join(", ", ServerUtil.getValidDimensions()))); + continue; + } + + if (!blacklistedDimensions.contains(dimension)) { + ChatUtils.sendError(player, String.format(UserError.DIMENSION_IS_NOT_BLACKLISTED.getValue(), dimension)); + continue; + } + + if (!blacklistDao.delete(dimension)) { + Bukkit.getLogger().severe(String.format("Failed to remove dimension from blacklist. %s", dimension)); + ChatUtils.sendError(player, PluginError.REMOVE_DIMENSION_FAILED.getValue()); + continue; + } + + ChatUtils.sendSuccess(player, String.format( + ConfigUtil.getConfig().getString("dimensionRemovedFromBlacklist", UserSuccess.DIMENSION_REMOVED_FROM_BLACKLIST.getValue()), + dimension + )); + } + + return true; + } + + private boolean list(Player player, String label, String[] extraArgs) { + if (!player.hasPermission("sh2.get-blacklisted-dimensions")) { + ChatUtils.invalidPermissions(player); + return true; + } + + if (extraArgs.length > 0) { + ChatUtils.incorrectNumArguments(player); + ChatUtils.sendInfo(player, String.format(UserInfo.BLACKLIST_USAGE.getValue(), label)); + return true; + } + + Dao blacklistDao = new BlacklistDao(); + List blacklistedDimensions = blacklistDao.getAll(); + + if (blacklistedDimensions.isEmpty()) { + ChatUtils.sendInfo(player, UserInfo.NO_BLACKLISTED_DIMENSIONS.getValue()); + return true; + } + + String blacklist = "Blacklisted Dimensions: " + blacklistedDimensions; + ChatUtils.sendInfo(player, blacklist); + return true; + } +} diff --git a/src/main/java/com/samleighton/sethomestwo/commands/CreateHome.java b/src/main/java/com/samleighton/sethomestwo/commands/CreateHome.java index 304a115..51b3e66 100644 --- a/src/main/java/com/samleighton/sethomestwo/commands/CreateHome.java +++ b/src/main/java/com/samleighton/sethomestwo/commands/CreateHome.java @@ -3,11 +3,11 @@ import com.samleighton.sethomestwo.dao.Dao; import com.samleighton.sethomestwo.dao.HomesDao; import com.samleighton.sethomestwo.enums.UserError; -import com.samleighton.sethomestwo.enums.UserInfo; import com.samleighton.sethomestwo.enums.UserSuccess; import com.samleighton.sethomestwo.models.Home; import com.samleighton.sethomestwo.utils.ChatUtils; import com.samleighton.sethomestwo.utils.ConfigUtil; +import com.samleighton.sethomestwo.utils.HomeNameValidator; import com.samleighton.sethomestwo.utils.HomesUtil; import com.samleighton.sethomestwo.utils.ServerUtil; import net.luckperms.api.LuckPerms; @@ -45,13 +45,6 @@ public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command return true; } - // Guard to ensure we have minimum number of args - if (args.length < 1) { - ChatUtils.incorrectNumArguments(player); - ChatUtils.sendInfo(player, UserInfo.CREATE_HOME_USAGE.getValue()); - return true; - } - // Guard to check if player has exceeded the max number of homes if (this.maxHomesReached(player, homesDao)){ String errorMessage = ConfigUtil.getConfig().getString("maxHomesReached", UserError.MAX_HOMES.getValue()); @@ -62,50 +55,63 @@ public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command String playerDimension = player.getWorld().getEnvironment().toString(); // Check if player is in a blacklisted dimension before creating home - if (ServerUtil.isDimensionBlacklisted(playerDimension)) { + if (!player.hasPermission("sh2.bypass-blacklist") && ServerUtil.isWorldBlacklisted(player.getWorld())) { String errorMessage = ConfigUtil.getConfig().getString("dimensionBlacklisted", UserError.DIMENSION_IS_BLACKLISTED.getValue()); ChatUtils.sendError(player, errorMessage); return true; } - // Extract parameters from command arguments - String homeName = args[0]; + // Extract parameters from command arguments. A bare command is the v1 + // form, naming the home rather than erroring. + String homeName = HomeNameValidator.normalise( + args.length < 1 ? HomesUtil.DEFAULT_HOME_NAME : args[0]); + + // The same shape rules the GUI rename applies. A double space yields an + // empty first argument, and an empty name leaves a home that no command + // can address, so none can delete it either. + int maxNameLength = ConfigUtil.getConfig().getInt("maxHomeNameLength", 32); + + switch (HomeNameValidator.validate(homeName, maxNameLength)) { + case EMPTY: + ChatUtils.sendError(player, ConfigUtil.getConfig().getString( + "invalidHomeName", UserError.INVALID_HOME_NAME.getValue())); + return true; + case TOO_LONG: + String tooLong = ConfigUtil.getConfig().getString( + "homeNameTooLong", UserError.HOME_NAME_TOO_LONG.getValue()); + ChatUtils.sendError(player, String.format(tooLong, maxNameLength)); + return true; + default: + break; + } - String material = ""; - if (args.length > 1) - material = args[1]; + Material mat = null; + int descriptionStart = 1; - // Guard to ensure material entered is a valid material - boolean isMaterialBlankOrDefault = material.equalsIgnoreCase("d") || material.equalsIgnoreCase("default") || material.equalsIgnoreCase(""); - Material mat = isMaterialBlankOrDefault ? Material.WHITE_WOOL : Material.matchMaterial(material); - if (mat == null) { - String errorMessage = ConfigUtil.getConfig().getString("invalidHomeItem", UserError.INVALID_MATERIAL.getValue()); - ChatUtils.sendError(player, errorMessage); - return true; - } - if (!mat.isItem()) { - String errorMessage = ConfigUtil.getConfig().getString("invalidHomeItem", UserError.INVALID_MATERIAL.getValue()); - ChatUtils.sendError(player, errorMessage); - return true; - } + if (args.length > 1) { + String candidate = args[1]; - material = mat.name(); - String description = null; - StringBuilder stringBuilder = new StringBuilder(); - - // Build description from leftover arguments - if (args.length > 2) { - String[] remainingArgs = Arrays.copyOfRange(args, 2, args.length); - for (int i = 0; i < remainingArgs.length; i++) { - String arg = remainingArgs[i]; - if (i == remainingArgs.length - 1) { - stringBuilder.append(arg); - } else { - stringBuilder.append(arg).append(" "); + if (candidate.isEmpty() || candidate.equalsIgnoreCase("d") || candidate.equalsIgnoreCase("default")) { + mat = defaultHomeItem(); + descriptionStart = 2; + } else { + Material matched = Material.matchMaterial(candidate); + if (matched != null && matched.isItem()) { + mat = matched; + descriptionStart = 2; } } + } + + // An argument 2 that names no item is description text, not an error, so + // that the v1 form "/sethome base my main base" still works. + if (mat == null) mat = defaultHomeItem(); + + String material = mat.name(); - description = stringBuilder.toString(); + String description = null; + if (args.length > descriptionStart) { + description = String.join(" ", Arrays.copyOfRange(args, descriptionStart, args.length)); } // Duplicate name guard @@ -133,11 +139,13 @@ public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command } String message = ConfigUtil.getConfig().getString("homeCreated", UserSuccess.HOME_CREATED.getValue()); - ChatUtils.sendSuccess(player, String.format(message, homeName)); + ChatUtils.sendSuccess(player, String.format(message, homeName, material)); return true; } private boolean maxHomesReached(Player player, Dao homesDao){ + if (player.hasPermission("sh2.bypass-max-homes")) return false; + boolean isMaxHomesEnabled = ConfigUtil.getConfig().getBoolean("maxHomeEnabled", false); if (!isMaxHomesEnabled) return false; @@ -176,4 +184,16 @@ private boolean maxHomesReached(Player player, Dao homesDao){ int playersHomeCount = HomesUtil.getPlayerHomesCount(homesDao, player.getUniqueId()); return playersHomeCount >= maxHomesAllowed; } + + /** + * The icon a home takes when none is given. A configured value that names no + * item falls back to white wool, because a non-item material stored as an + * icon makes HomesGui throw when it builds the ItemStack. + */ + private static Material defaultHomeItem() { + Material configured = Material.matchMaterial( + ConfigUtil.getConfig().getString("defaultHomeItem", "white_wool")); + + return configured != null && configured.isItem() ? configured : Material.WHITE_WOOL; + } } diff --git a/src/main/java/com/samleighton/sethomestwo/commands/DeleteHome.java b/src/main/java/com/samleighton/sethomestwo/commands/DeleteHome.java index ce24f71..4bd394f 100644 --- a/src/main/java/com/samleighton/sethomestwo/commands/DeleteHome.java +++ b/src/main/java/com/samleighton/sethomestwo/commands/DeleteHome.java @@ -33,7 +33,7 @@ public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command // Args length guard if (args.length != 1) { ChatUtils.incorrectNumArguments(player); - ChatUtils.sendInfo(player, UserError.DELETE_HOME_USAGE.getValue()); + ChatUtils.sendInfo(player, String.format(UserError.DELETE_HOME_USAGE.getValue(), s)); return true; } diff --git a/src/main/java/com/samleighton/sethomestwo/commands/DeletePlayerHome.java b/src/main/java/com/samleighton/sethomestwo/commands/DeletePlayerHome.java new file mode 100644 index 0000000..fb51a89 --- /dev/null +++ b/src/main/java/com/samleighton/sethomestwo/commands/DeletePlayerHome.java @@ -0,0 +1,76 @@ +package com.samleighton.sethomestwo.commands; + +import com.samleighton.sethomestwo.dao.HomesDao; +import com.samleighton.sethomestwo.enums.UserError; +import com.samleighton.sethomestwo.enums.UserInfo; +import com.samleighton.sethomestwo.enums.UserSuccess; +import com.samleighton.sethomestwo.models.Home; +import com.samleighton.sethomestwo.utils.ChatUtils; +import com.samleighton.sethomestwo.utils.ConfigUtil; +import com.samleighton.sethomestwo.utils.ServerUtil; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +import java.util.UUID; + +public class DeletePlayerHome implements CommandExecutor { + + @Override + public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String label, String[] args) { + if (!(commandSender instanceof Player)) { + commandSender.sendMessage(UserError.PLAYERS_ONLY.getValue()); + return true; + } + + Player admin = (Player) commandSender; + + if (!admin.hasPermission("sh2.delete-player-home")) { + ChatUtils.invalidPermissions(admin); + return true; + } + + if (args.length != 2) { + ChatUtils.incorrectNumArguments(admin); + ChatUtils.sendInfo(admin, String.format(UserInfo.DELETE_PLAYER_HOME_USAGE.getValue(), label)); + return true; + } + + String uuid = ServerUtil.getPlayerUUID(args[0]); + + if (uuid == null) { + ChatUtils.sendError(admin, ConfigUtil.getConfig().getString( + "playerNotFound", UserError.PLAYER_NOT_FOUND.getValue())); + return true; + } + + HomesDao homesDao = new HomesDao(true); + Home home = homesDao.get(UUID.fromString(uuid), args[1]); + + if (home == null) { + ChatUtils.sendError(admin, String.format(ConfigUtil.getConfig().getString( + "homeDoesNotExist", UserError.HOME_DOES_NOT_EXIST.getValue()), args[1])); + return true; + } + + if (!homesDao.delete(home)) { + ChatUtils.pluginError(admin); + return true; + } + + String deleted = ConfigUtil.getConfig().getString("playerHomeDeleted", UserSuccess.PLAYER_HOME_DELETED.getValue()); + ChatUtils.sendSuccess(admin, String.format(deleted, ownerName(home, args[0]), home.getName())); + return true; + } + + /** + * The owner's name as stored against the home, which is canonically cased. + * Falls back to what the admin typed for a home saved before names were + * recorded. + */ + private static String ownerName(Home home, String typed) { + return home.getPlayerName() == null ? typed : home.getPlayerName(); + } +} diff --git a/src/main/java/com/samleighton/sethomestwo/commands/GetBlacklistedDimensions.java b/src/main/java/com/samleighton/sethomestwo/commands/GetBlacklistedDimensions.java deleted file mode 100644 index 0b0048c..0000000 --- a/src/main/java/com/samleighton/sethomestwo/commands/GetBlacklistedDimensions.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.samleighton.sethomestwo.commands; - -import com.samleighton.sethomestwo.dao.BlacklistDao; -import com.samleighton.sethomestwo.dao.Dao; -import com.samleighton.sethomestwo.enums.UserError; -import com.samleighton.sethomestwo.enums.UserInfo; -import com.samleighton.sethomestwo.utils.ChatUtils; -import org.bukkit.command.Command; -import org.bukkit.command.CommandExecutor; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; -import org.jetbrains.annotations.NotNull; - -import java.util.List; - -public class GetBlacklistedDimensions implements CommandExecutor { - public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String s, String[] args) { - if (!(commandSender instanceof Player)) { - commandSender.sendMessage(UserError.PLAYERS_ONLY.getValue()); - return true; - } - - Player player = (Player) commandSender; - - // Permission guard - if(!player.hasPermission("sh2.get-blacklisted-dimensions")){ - ChatUtils.invalidPermissions(player); - return true; - } - - // Args length guard - if (args.length > 0) { - ChatUtils.incorrectNumArguments(player); - ChatUtils.sendInfo(player, UserInfo.GET_BLACKLIST_USAGE.getValue()); - return true; - } - - Dao blacklistDao = new BlacklistDao(); - List blacklistedDimensions = blacklistDao.getAll(); - - if (blacklistedDimensions.isEmpty()) { - ChatUtils.sendInfo(player, UserInfo.NO_BLACKLISTED_DIMENSIONS.getValue()); - return true; - } - - String blacklist = "Blacklisted Dimensions: " + blacklistedDimensions; - ChatUtils.sendInfo(player, blacklist); - return true; - } -} diff --git a/src/main/java/com/samleighton/sethomestwo/commands/GetPlayerHomes.java b/src/main/java/com/samleighton/sethomestwo/commands/GetPlayerHomes.java index 3122792..9449af2 100644 --- a/src/main/java/com/samleighton/sethomestwo/commands/GetPlayerHomes.java +++ b/src/main/java/com/samleighton/sethomestwo/commands/GetPlayerHomes.java @@ -48,25 +48,26 @@ public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command // Args length guard if (args.length != 1) { ChatUtils.incorrectNumArguments(requester); - ChatUtils.sendError(requester, UserInfo.GET_PLAYER_HOMES_USAGE.getValue()); + ChatUtils.sendError(requester, String.format(UserInfo.GET_PLAYER_HOMES_USAGE.getValue(), s)); return true; } String uuidString = ServerUtil.getPlayerUUID(args[0]); - // Add a check for if player is online/exists + // null means the name matched no online player and no stored home owner if (uuidString == null) { - ChatUtils.sendError(requester, UserError.PLAYER_NOT_ONLINE.getValue()); + ChatUtils.sendError(requester, ConfigUtil.getConfig().getString( + "playerNotFound", UserError.PLAYER_NOT_FOUND.getValue())); return true; } - Dao homesDao = new HomesDao(true); + Dao homesDao = new HomesDao(requester.hasPermission("sh2.bypass-blacklist")); List playersHomes = homesDao.getAll(UUID.fromString(uuidString)); - Player player = Bukkit.getPlayer(UUID.fromString(uuidString)); - if (player == null) return true; + Player target = Bukkit.getPlayer(UUID.fromString(uuidString)); + String targetName = target == null ? args[0] : target.getDisplayName(); - HomesGui adminGui = new HomesGui(requester, "Homes of " + player.getDisplayName()); + HomesGui adminGui = new HomesGui(requester, "Homes of " + targetName); adminGui.setHomes(playersHomes); GuiSession session = plugin.getGuiSessionMap().computeIfAbsent(requester.getUniqueId(), uuid -> new GuiSession(new HomesGui(requester))); @@ -74,7 +75,7 @@ public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command adminGui.displayInventory(requester); if (ConfigUtil.getDebugLevel().equals(DebugLevel.INFO)) - Bukkit.getLogger().info(String.format("%s is viewing homes of player %s", requester.getDisplayName(), player.getDisplayName())); + Bukkit.getLogger().info(String.format("%s is viewing homes of player %s", requester.getDisplayName(), targetName)); return true; } diff --git a/src/main/java/com/samleighton/sethomestwo/commands/GoHome.java b/src/main/java/com/samleighton/sethomestwo/commands/GoHome.java index c038c9d..fd0aca0 100644 --- a/src/main/java/com/samleighton/sethomestwo/commands/GoHome.java +++ b/src/main/java/com/samleighton/sethomestwo/commands/GoHome.java @@ -5,6 +5,8 @@ import com.samleighton.sethomestwo.enums.UserError; import com.samleighton.sethomestwo.models.Home; import com.samleighton.sethomestwo.utils.ChatUtils; +import com.samleighton.sethomestwo.utils.ConfigUtil; +import com.samleighton.sethomestwo.utils.HomesUtil; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; @@ -24,8 +26,9 @@ public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command Player player = (Player) commandSender; - // Args length guard - if(args.length != 1){ + // Args length guard. A bare command is the v1 form, so only too many + // arguments is an error. + if(args.length > 1){ ChatUtils.incorrectNumArguments(player); return true; } @@ -37,8 +40,8 @@ public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command } // Get players home dao instance - String desiredHomeName = args[0]; - Dao homesDao = new HomesDao(); + String desiredHomeName = args.length < 1 ? HomesUtil.DEFAULT_HOME_NAME : args[0]; + Dao homesDao = new HomesDao(player.hasPermission("sh2.bypass-blacklist")); ArrayList playerHomes = (ArrayList) homesDao.getAll(player.getUniqueId()); Home homeToTeleportTo = null; @@ -51,7 +54,8 @@ public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command // Home does not exist guard if(homeToTeleportTo == null){ - ChatUtils.sendError(player, UserError.HOME_DOES_NOT_EXIST.getValue()); + String message = ConfigUtil.getConfig().getString("homeDoesNotExist", UserError.HOME_DOES_NOT_EXIST.getValue()); + ChatUtils.sendError(player, String.format(message, desiredHomeName)); return true; } diff --git a/src/main/java/com/samleighton/sethomestwo/commands/GoPlayerHome.java b/src/main/java/com/samleighton/sethomestwo/commands/GoPlayerHome.java new file mode 100644 index 0000000..7af2ede --- /dev/null +++ b/src/main/java/com/samleighton/sethomestwo/commands/GoPlayerHome.java @@ -0,0 +1,68 @@ +package com.samleighton.sethomestwo.commands; + +import com.samleighton.sethomestwo.dao.HomesDao; +import com.samleighton.sethomestwo.enums.UserError; +import com.samleighton.sethomestwo.enums.UserInfo; +import com.samleighton.sethomestwo.models.Home; +import com.samleighton.sethomestwo.utils.ChatUtils; +import com.samleighton.sethomestwo.utils.ConfigUtil; +import com.samleighton.sethomestwo.utils.ServerUtil; +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +import java.util.UUID; + +public class GoPlayerHome implements CommandExecutor { + + @Override + public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String label, String[] args) { + if (!(commandSender instanceof Player)) { + commandSender.sendMessage(UserError.PLAYERS_ONLY.getValue()); + return true; + } + + Player admin = (Player) commandSender; + + if (!admin.hasPermission("sh2.go-player-home")) { + ChatUtils.invalidPermissions(admin); + return true; + } + + if (args.length != 2) { + ChatUtils.incorrectNumArguments(admin); + ChatUtils.sendInfo(admin, String.format(UserInfo.GO_PLAYER_HOME_USAGE.getValue(), label)); + return true; + } + + String uuid = ServerUtil.getPlayerUUID(args[0]); + + if (uuid == null) { + ChatUtils.sendError(admin, ConfigUtil.getConfig().getString( + "playerNotFound", UserError.PLAYER_NOT_FOUND.getValue())); + return true; + } + + Home home = new HomesDao(true).get(UUID.fromString(uuid), args[1]); + + if (home == null) { + ChatUtils.sendError(admin, String.format(ConfigUtil.getConfig().getString( + "homeDoesNotExist", UserError.HOME_DOES_NOT_EXIST.getValue()), args[1])); + return true; + } + + // get applies no blacklist rule of its own, unlike getAll, so the node + // has to be honoured here or it could not be taken away. Clearing the + // flag lets Home.teleport send the same refusal a player would see. + if (!admin.hasPermission("sh2.bypass-blacklist") + && ServerUtil.isWorldBlacklisted(Bukkit.getWorld(UUID.fromString(home.getWorld())))) { + home.setCanTeleport(false); + } + + home.teleport(admin); + return true; + } +} diff --git a/src/main/java/com/samleighton/sethomestwo/commands/ImportHomes.java b/src/main/java/com/samleighton/sethomestwo/commands/ImportHomes.java index 672d530..ee34a3f 100644 --- a/src/main/java/com/samleighton/sethomestwo/commands/ImportHomes.java +++ b/src/main/java/com/samleighton/sethomestwo/commands/ImportHomes.java @@ -26,9 +26,10 @@ public class ImportHomes implements CommandExecutor { @Override public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String s, String[] args) { - // Console and players may both run this; permission handled by plugin.yml (default op) + // Console and players may both run this; the permission is enforced by + // plugin.yml before onCommand is reached. if (args.length < 1 || !SOURCES.containsKey(args[0].toLowerCase())) { - commandSender.sendMessage(String.format("Usage: /import-homes <%s> [confirm]", String.join("|", SOURCES.keySet()))); + commandSender.sendMessage(String.format("Usage: /%s <%s> [confirm]", s, String.join("|", SOURCES.keySet()))); return true; } @@ -37,10 +38,16 @@ public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command ImportReport report = importer.run(dryRun); commandSender.sendMessage(report.summary(dryRun)); + if (report.hasBlacklistActivity()) { + commandSender.sendMessage(report.blacklistSummary(dryRun)); + } for (String warning : report.warnings) { commandSender.sendMessage("Warning: " + warning); } - if (dryRun && report.imported > 0) { + for (String note : report.configNotes) { + commandSender.sendMessage("Config: " + note); + } + if (dryRun && (report.imported > 0 || report.blacklistImported > 0)) { commandSender.sendMessage(String.format("Dry run only. Run '/import-homes %s confirm' to apply.", importer.sourceName())); } return true; diff --git a/src/main/java/com/samleighton/sethomestwo/commands/ListHomes.java b/src/main/java/com/samleighton/sethomestwo/commands/ListHomes.java index cee44ef..f1ad07c 100644 --- a/src/main/java/com/samleighton/sethomestwo/commands/ListHomes.java +++ b/src/main/java/com/samleighton/sethomestwo/commands/ListHomes.java @@ -4,6 +4,7 @@ import com.samleighton.sethomestwo.dao.HomesDao; import com.samleighton.sethomestwo.enums.UserError; import com.samleighton.sethomestwo.enums.UserInfo; +import com.samleighton.sethomestwo.utils.ConfigUtil; import com.samleighton.sethomestwo.models.Home; import com.samleighton.sethomestwo.utils.ChatUtils; import net.md_5.bungee.api.chat.ClickEvent; @@ -23,6 +24,8 @@ import java.util.UUID; public class ListHomes implements CommandExecutor { + // Bungee chat is deprecated upstream in favour of Adventure, which Spigot servers do not have. + @SuppressWarnings("deprecation") @Override public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { // Players only guard @@ -39,13 +42,12 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command return true; } - Dao homesDao = new HomesDao(); + Dao homesDao = new HomesDao(player.hasPermission("sh2.bypass-blacklist")); List playersHomes = homesDao.getAll(player.getUniqueId()); // Player has no homes guard if (playersHomes.isEmpty()) { - ChatUtils.sendInfo(player, UserInfo.NO_HOMES.getValue()); - ChatUtils.sendInfo(player, UserInfo.CREATE_HOME_USAGE.getValue()); + ChatUtils.sendInfo(player, ConfigUtil.getConfig().getString("noHomes", UserInfo.NO_HOMES.getValue())); return true; } diff --git a/src/main/java/com/samleighton/sethomestwo/commands/MoveHome.java b/src/main/java/com/samleighton/sethomestwo/commands/MoveHome.java new file mode 100644 index 0000000..a6751d8 --- /dev/null +++ b/src/main/java/com/samleighton/sethomestwo/commands/MoveHome.java @@ -0,0 +1,61 @@ +package com.samleighton.sethomestwo.commands; + +import com.samleighton.sethomestwo.dao.HomesDao; +import com.samleighton.sethomestwo.enums.UserError; +import com.samleighton.sethomestwo.enums.UserInfo; +import com.samleighton.sethomestwo.enums.UserSuccess; +import com.samleighton.sethomestwo.gui.HomeActionsGui; +import com.samleighton.sethomestwo.models.Home; +import com.samleighton.sethomestwo.utils.ChatUtils; +import com.samleighton.sethomestwo.utils.ConfigUtil; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +public class MoveHome implements CommandExecutor { + + @Override + public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String label, String[] args) { + if (!(commandSender instanceof Player)) { + commandSender.sendMessage(UserError.PLAYERS_ONLY.getValue()); + return true; + } + + Player player = (Player) commandSender; + + if (!player.hasPermission("sh2.move-home")) { + ChatUtils.invalidPermissions(player); + return true; + } + + if (args.length != 1) { + ChatUtils.incorrectNumArguments(player); + ChatUtils.sendInfo(player, String.format(UserInfo.MOVE_HOME_USAGE.getValue(), label)); + return true; + } + + Home home = new HomesDao().get(player.getUniqueId(), args[0]); + + switch (HomeActionsGui.applyMove(player, home)) { + case GONE: + ChatUtils.sendError(player, String.format(ConfigUtil.getConfig().getString( + "homeDoesNotExist", UserError.HOME_DOES_NOT_EXIST.getValue()), args[0])); + break; + case BLACKLISTED: + ChatUtils.sendError(player, ConfigUtil.getConfig().getString( + "cannotMoveToBlacklistedDimension", UserError.CANNOT_MOVE_TO_BLACKLISTED_DIMENSION.getValue())); + break; + case UPDATE_FAILED: + ChatUtils.pluginError(player); + break; + case MOVED: + String moved = ConfigUtil.getConfig().getString("homeMoved", UserSuccess.HOME_MOVED.getValue()); + ChatUtils.sendSuccess(player, String.format(moved, home.getName())); + break; + } + + return true; + } +} diff --git a/src/main/java/com/samleighton/sethomestwo/commands/MovePlayerHome.java b/src/main/java/com/samleighton/sethomestwo/commands/MovePlayerHome.java new file mode 100644 index 0000000..c3e0c32 --- /dev/null +++ b/src/main/java/com/samleighton/sethomestwo/commands/MovePlayerHome.java @@ -0,0 +1,81 @@ +package com.samleighton.sethomestwo.commands; + +import com.samleighton.sethomestwo.dao.HomesDao; +import com.samleighton.sethomestwo.enums.UserError; +import com.samleighton.sethomestwo.enums.UserInfo; +import com.samleighton.sethomestwo.enums.UserSuccess; +import com.samleighton.sethomestwo.gui.HomeActionsGui; +import com.samleighton.sethomestwo.models.Home; +import com.samleighton.sethomestwo.utils.ChatUtils; +import com.samleighton.sethomestwo.utils.ConfigUtil; +import com.samleighton.sethomestwo.utils.ServerUtil; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +import java.util.UUID; + +public class MovePlayerHome implements CommandExecutor { + + @Override + public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String label, String[] args) { + if (!(commandSender instanceof Player)) { + commandSender.sendMessage(UserError.PLAYERS_ONLY.getValue()); + return true; + } + + Player admin = (Player) commandSender; + + if (!admin.hasPermission("sh2.move-player-home")) { + ChatUtils.invalidPermissions(admin); + return true; + } + + if (args.length != 2) { + ChatUtils.incorrectNumArguments(admin); + ChatUtils.sendInfo(admin, String.format(UserInfo.MOVE_PLAYER_HOME_USAGE.getValue(), label)); + return true; + } + + String uuid = ServerUtil.getPlayerUUID(args[0]); + + if (uuid == null) { + ChatUtils.sendError(admin, ConfigUtil.getConfig().getString( + "playerNotFound", UserError.PLAYER_NOT_FOUND.getValue())); + return true; + } + + Home home = new HomesDao(true).get(UUID.fromString(uuid), args[1]); + + switch (HomeActionsGui.applyMove(admin, home)) { + case GONE: + ChatUtils.sendError(admin, String.format(ConfigUtil.getConfig().getString( + "homeDoesNotExist", UserError.HOME_DOES_NOT_EXIST.getValue()), args[1])); + break; + case BLACKLISTED: + ChatUtils.sendError(admin, ConfigUtil.getConfig().getString( + "cannotMoveToBlacklistedDimension", UserError.CANNOT_MOVE_TO_BLACKLISTED_DIMENSION.getValue())); + break; + case UPDATE_FAILED: + ChatUtils.pluginError(admin); + break; + case MOVED: + String moved = ConfigUtil.getConfig().getString("playerHomeMoved", UserSuccess.PLAYER_HOME_MOVED.getValue()); + ChatUtils.sendSuccess(admin, String.format(moved, ownerName(home, args[0]), home.getName())); + break; + } + + return true; + } + + /** + * The owner's name as stored against the home, which is canonically cased. + * Falls back to what the admin typed for a home saved before names were + * recorded. + */ + private static String ownerName(Home home, String typed) { + return home.getPlayerName() == null ? typed : home.getPlayerName(); + } +} diff --git a/src/main/java/com/samleighton/sethomestwo/commands/OpenHomesGui.java b/src/main/java/com/samleighton/sethomestwo/commands/OpenHomesGui.java index c8fe3af..9bc9a95 100644 --- a/src/main/java/com/samleighton/sethomestwo/commands/OpenHomesGui.java +++ b/src/main/java/com/samleighton/sethomestwo/commands/OpenHomesGui.java @@ -4,6 +4,7 @@ import com.samleighton.sethomestwo.dao.Dao; import com.samleighton.sethomestwo.dao.HomesDao; import com.samleighton.sethomestwo.enums.UserError; +import com.samleighton.sethomestwo.enums.UserInfo; import com.samleighton.sethomestwo.gui.GuiSession; import com.samleighton.sethomestwo.gui.HomesGui; import com.samleighton.sethomestwo.models.Home; @@ -41,12 +42,12 @@ public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command return true; } - Dao homesDao = new HomesDao(); + Dao homesDao = new HomesDao(player.hasPermission("sh2.bypass-blacklist")); List playersHomes = homesDao.getAll(player.getUniqueId()); // Guard for no homes yet if (playersHomes == null || playersHomes.isEmpty()) { - ChatUtils.sendInfo(player, ConfigUtil.getConfig().getString("noHomes", UserError.NO_HOMES.getValue())); + ChatUtils.sendInfo(player, ConfigUtil.getConfig().getString("noHomes", UserInfo.NO_HOMES.getValue())); return true; } diff --git a/src/main/java/com/samleighton/sethomestwo/commands/RemoveDimensionFromBlacklist.java b/src/main/java/com/samleighton/sethomestwo/commands/RemoveDimensionFromBlacklist.java deleted file mode 100644 index 3a81597..0000000 --- a/src/main/java/com/samleighton/sethomestwo/commands/RemoveDimensionFromBlacklist.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.samleighton.sethomestwo.commands; - -import com.samleighton.sethomestwo.dao.BlacklistDao; -import com.samleighton.sethomestwo.dao.Dao; -import com.samleighton.sethomestwo.enums.*; -import com.samleighton.sethomestwo.utils.ChatUtils; -import com.samleighton.sethomestwo.utils.ConfigUtil; -import com.samleighton.sethomestwo.utils.ServerUtil; -import org.bukkit.Bukkit; -import org.bukkit.command.Command; -import org.bukkit.command.CommandExecutor; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; -import org.jetbrains.annotations.NotNull; - -import java.util.List; - -public class RemoveDimensionFromBlacklist implements CommandExecutor { - public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String s, String[] args) { - // Sender must be player guard - if (!(commandSender instanceof Player)) { - commandSender.sendMessage(UserError.PLAYERS_ONLY.getValue()); - return true; - } - - Player player = (Player) commandSender; - - // Permission guard - if(!player.hasPermission("sh2.remove-from-blacklist")){ - ChatUtils.invalidPermissions(player); - return true; - } - - // Args length guard - if (args.length < 1) { - ChatUtils.incorrectNumArguments(player); - ChatUtils.sendInfo(player, UserInfo.REMOVE_FROM_BLACKLIST_USAGE.getValue()); - return true; - } - - Dao blacklistDao = new BlacklistDao(); - List blacklistedDimensions = blacklistDao.getAll(); - - // Loop over inputs assuming each is a dimension - for (String dimension : args) { - // Valid dimension guard - if (!ServerUtil.getValidDimensions().contains(dimension)) { - ChatUtils.sendError(player, String.format(UserError.INVALID_DIMENSION.getValue(), dimension)); - continue; - } - - // Currently in blacklist guard - if(!blacklistedDimensions.contains(dimension)) { - ChatUtils.sendError(player, String.format(UserError.DIMENSION_IS_NOT_BLACKLISTED.getValue(), dimension)); - continue; - } - - // Perform delete action and obtain result - boolean success = blacklistDao.delete(dimension); - - // Guard for successful addition of blacklist - if (!success && ConfigUtil.getDebugLevel().equals(DebugLevel.ERROR)) { - Bukkit.getLogger().info(String.format("Failed to remove dimension from blacklist. %s", dimension)); - ChatUtils.sendError(player, PluginError.REMOVE_DIMENSION_FAILED.getValue()); - } - - ChatUtils.sendSuccess(player, String.format( - ConfigUtil.getConfig().getString("dimensionRemovedFromBlacklist", UserSuccess.DIMENSION_REMOVED_FROM_BLACKLIST.getValue()), - dimension - )); - } - - return true; - } -} diff --git a/src/main/java/com/samleighton/sethomestwo/commands/SetMaxHomes.java b/src/main/java/com/samleighton/sethomestwo/commands/SetMaxHomes.java index a533833..f5abd53 100644 --- a/src/main/java/com/samleighton/sethomestwo/commands/SetMaxHomes.java +++ b/src/main/java/com/samleighton/sethomestwo/commands/SetMaxHomes.java @@ -24,7 +24,7 @@ public class SetMaxHomes implements CommandExecutor { public SetMaxHomes(Plugin plugin) { this.plugin = plugin; } - public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String s, String[] args) { + public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String label, String[] args) { if (!(commandSender instanceof Player)) { commandSender.sendMessage(UserError.PLAYERS_ONLY.getValue()); return false; @@ -46,13 +46,13 @@ public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command // Depending on if grouping is singular or groups, guard against incorrect number of args if (maxHomesType.equals("singular") && args.length != 1) { ChatUtils.incorrectNumArguments(player); - ChatUtils.sendError(player, UserError.SET_MAX_HOMES_SINGULAR.getValue()); + ChatUtils.sendError(player, String.format(UserError.SET_MAX_HOMES_SINGULAR.getValue(), label)); return false; } if (maxHomesType.equals("groups") && args.length != 2) { ChatUtils.incorrectNumArguments(player); - ChatUtils.sendError(player, UserError.SET_MAX_HOMES_GROUPS.getValue()); + ChatUtils.sendError(player, String.format(UserError.SET_MAX_HOMES_GROUPS.getValue(), label)); return false; } diff --git a/src/main/java/com/samleighton/sethomestwo/connections/ConnectionManager.java b/src/main/java/com/samleighton/sethomestwo/connections/ConnectionManager.java index 14a6e93..98a47c5 100644 --- a/src/main/java/com/samleighton/sethomestwo/connections/ConnectionManager.java +++ b/src/main/java/com/samleighton/sethomestwo/connections/ConnectionManager.java @@ -1,6 +1,7 @@ package com.samleighton.sethomestwo.connections; import com.samleighton.sethomestwo.SetHomesTwo; +import com.samleighton.sethomestwo.metrics.Errors; import org.bukkit.Bukkit; import java.sql.Connection; @@ -34,6 +35,7 @@ public boolean createConnection(String key, String dbName) { return true; } catch (SQLException e) { Bukkit.getLogger().severe(String.format("There was an issue creating the database %s", dbName)); + Errors.count(Errors.DB_CONNECT); } return false; diff --git a/src/main/java/com/samleighton/sethomestwo/dao/HomesDao.java b/src/main/java/com/samleighton/sethomestwo/dao/HomesDao.java index 0a4a439..a898c30 100644 --- a/src/main/java/com/samleighton/sethomestwo/dao/HomesDao.java +++ b/src/main/java/com/samleighton/sethomestwo/dao/HomesDao.java @@ -5,24 +5,31 @@ import com.samleighton.sethomestwo.utils.ServerUtil; import org.bukkit.Bukkit; import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.UUID; public class HomesDao extends SQLiteDao implements Dao { private final String TABLE_NAME = "players_homes"; - private boolean isAdmin = false; + private boolean bypassBlacklist = false; public HomesDao(){ super(); } - public HomesDao(boolean isAdmin){ + /** + * @param bypassBlacklist true to read homes without the blacklist marking them + * unreachable. Both the admin views of another player's + * homes and a holder of sh2.bypass-blacklist pass true. + */ + public HomesDao(boolean bypassBlacklist){ super(); - this.isAdmin = isAdmin; + this.bypassBlacklist = bypassBlacklist; } @Override @@ -43,7 +50,8 @@ public List getAll(Object... keys) { if (rs == null) return new ArrayList<>(); // Build list of homes - Dao blacklistEntryDao = new BlacklistDao(); + // Read once, not once per row. + List blacklistedWorlds = new BlacklistDao().getAll(); List playerHomes = new ArrayList<>(); try { while (rs.next()) { @@ -64,15 +72,13 @@ public List getAll(Object... keys) { rs.getString("dimension") ); home.setId(rs.getInt("id")); + home.setPlayerName(rs.getString("player_name")); - String dimension = home.getDimension(); - List blacklistedDimensions = blacklistEntryDao.getAll(); - Map blacklistedMap = ServerUtil.getDimensionsMap(); + World homeWorld = Bukkit.getWorld(UUID.fromString(home.getWorld())); + if (ServerUtil.isWorldBlacklisted(homeWorld, blacklistedWorlds)) { + if (!this.bypassBlacklist) home.setDescription("Cannot teleport here: dimension blacklisted"); - if (blacklistedDimensions.contains(blacklistedMap.get(dimension))) { - if(!this.isAdmin) home.setDescription("Cannot teleport here: dimension blacklisted"); - - home.setCanTeleport(this.isAdmin); + home.setCanTeleport(this.bypassBlacklist); } playerHomes.add(home); @@ -85,6 +91,38 @@ public List getAll(Object... keys) { return playerHomes; } + /** + * Every home name this player owns, as stored. Reads only the name column, + * so it works when a home's world no longer exists. + * + * @param playerUUID The owner + * @return The names, unordered; empty when the player has none + */ + public List namesFor(UUID playerUUID) { + List names = new ArrayList<>(); + + String sql = "select name from %s where player_uuid = ?"; + ResultSet rs = DatabaseUtil.fetch(this.conn, String.format(sql, TABLE_NAME), playerUUID.toString()); + + if (rs == null) return names; + + try { + while (rs.next()) { + names.add(rs.getString("name")); + } + } catch (SQLException e) { + Bukkit.getLogger().severe("There was an issue reading home names for player " + playerUUID); + Bukkit.getLogger().info(e.getMessage()); + } + + return names; + } + + /** + * Look a home up by owner and name. The name match ignores case, which is + * safe because {@link #nameExists} makes names unique per player ignoring + * case, so at most one home can ever match. + */ @Override public Home get(Object... keys) { UUID playerUUID = null; @@ -98,7 +136,7 @@ public Home get(Object... keys) { // Key guard if(homeName == null || playerUUID == null) return null; - String sql = "select * from %s where player_uuid = ? and name = ?"; + String sql = "select * from %s where player_uuid = ? and lower(name) = lower(?)"; ResultSet rs = DatabaseUtil.fetch(this.conn, String.format(sql, TABLE_NAME), playerUUID.toString(), homeName); if(rs == null) return null; @@ -124,6 +162,7 @@ public Home get(Object... keys) { rs.getString("dimension") ); home.setId(rs.getInt("id")); + home.setPlayerName(rs.getString("player_name")); } } catch (SQLException e) { Bukkit.getLogger().severe("There was an issue reading a home for player " + playerUUID); @@ -138,7 +177,14 @@ public boolean save(Object object) { if(!(object instanceof Home)) return false; Home home = (Home) object; - String sql = "insert into %s (player_uuid, world, material, name, description, x, y, z, pitch, yaw, dimension) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);"; + + String playerName = home.getPlayerName(); + if (playerName == null) { + Player owner = Bukkit.getPlayer(UUID.fromString(home.getUUIDBelongingTo())); + playerName = owner == null ? null : owner.getName(); + } + + String sql = "insert into %s (player_uuid, world, material, name, description, x, y, z, pitch, yaw, dimension, player_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);"; return DatabaseUtil.execute( this.conn, String.format(sql, TABLE_NAME), @@ -152,7 +198,8 @@ public boolean save(Object object) { home.getZ(), home.getPitch(), home.getYaw(), - home.getDimension() + home.getDimension(), + playerName ); } @@ -182,7 +229,13 @@ public boolean update(Object object) { return false; } - String sql = "update %s set material = ?, world = ?, name = ?, description = ?, x = ?, y = ?, z = ?, pitch = ?, yaw = ?, dimension = ? where id = ? and player_uuid = ?"; + String playerName = home.getPlayerName(); + if (playerName == null) { + Player owner = Bukkit.getPlayer(UUID.fromString(home.getUUIDBelongingTo())); + playerName = owner == null ? null : owner.getName(); + } + + String sql = "update %s set material = ?, world = ?, name = ?, description = ?, x = ?, y = ?, z = ?, pitch = ?, yaw = ?, dimension = ?, player_name = ? where id = ? and player_uuid = ?"; return DatabaseUtil.executeUpdate( this.conn, String.format(sql, TABLE_NAME), @@ -196,6 +249,7 @@ public boolean update(Object object) { home.getPitch(), home.getYaw(), home.getDimension(), + playerName, home.getId(), home.getUUIDBelongingTo() ) > 0; @@ -234,6 +288,7 @@ public Home getById(UUID playerUUID, int id) { rs.getString("dimension") ); home.setId(rs.getInt("id")); + home.setPlayerName(rs.getString("player_name")); return home; } } catch (SQLException e) { @@ -278,4 +333,78 @@ public boolean nameExists(UUID playerUUID, String name, Integer excludeId) { return false; } + + /** + * @return total homes on this server, 0 if the read fails + */ + public int countAll() { + return countQuery(String.format("select count(*) as total from %s", TABLE_NAME)); + } + + /** + * @return number of distinct players holding at least one home, 0 if the read fails + */ + public int countPlayersWithHomes() { + return countQuery(String.format("select count(distinct player_uuid) as total from %s", TABLE_NAME)); + } + + private int countQuery(String sql) { + ResultSet rs = DatabaseUtil.fetch(this.conn, sql); + if (rs == null) return 0; + try { + return rs.next() ? rs.getInt("total") : 0; + } catch (SQLException e) { + Bukkit.getLogger().severe("There was an issue counting homes."); + return 0; + } + } + + /** + * The UUID of the player who owns homes stored under this name, or null. + * A stale name can collide across two accounts (an old owner who renamed + * away and a new owner who took the name); returns null rather than + * guessing when more than one distinct UUID claims the name. + */ + public String uuidForName(String playerName) { + String sql = "select distinct player_uuid from players_homes where lower(player_name) = lower(?);"; + + try (PreparedStatement statement = this.conn.prepareStatement(sql)) { + statement.setString(1, playerName); + + try (ResultSet rs = statement.executeQuery()) { + if (!rs.next()) return null; + + String uuid = rs.getString("player_uuid"); + return rs.next() ? null : uuid; + } + } catch (SQLException e) { + Bukkit.getLogger().severe("Could not resolve player name " + playerName); + return null; + } + } + + /** + * Point every home this player owns at their current name, first stripping + * that name from any other UUID's rows so a stale prior owner can never + * make the name resolve ambiguously. The joining player takes precedence. + */ + public boolean refreshPlayerName(UUID playerUUID, String playerName) { + String clearSql = "update players_homes set player_name = null where lower(player_name) = lower(?) and player_uuid <> ?;"; + String claimSql = "update players_homes set player_name = ? where player_uuid = ?;"; + + try (PreparedStatement clear = this.conn.prepareStatement(clearSql); + PreparedStatement claim = this.conn.prepareStatement(claimSql)) { + clear.setString(1, playerName); + clear.setString(2, playerUUID.toString()); + clear.executeUpdate(); + + claim.setString(1, playerName); + claim.setString(2, playerUUID.toString()); + claim.executeUpdate(); + return true; + } catch (SQLException e) { + Bukkit.getLogger().severe("Could not refresh player name for " + playerUUID); + return false; + } + } } diff --git a/src/main/java/com/samleighton/sethomestwo/dao/TeleportAttemptsDao.java b/src/main/java/com/samleighton/sethomestwo/dao/TeleportAttemptsDao.java index 4d85030..89bd528 100644 --- a/src/main/java/com/samleighton/sethomestwo/dao/TeleportAttemptsDao.java +++ b/src/main/java/com/samleighton/sethomestwo/dao/TeleportAttemptsDao.java @@ -97,6 +97,7 @@ public TeleportAttempt get(Object... keys) { // Skip cancel on move check if (!ConfigUtil.getConfig().getBoolean("cancelOnMove", true)) return ta; + if (player.hasPermission("sh2.bypass-teleport-delay")) return ta; // Check if player has moved if (teleportStart.getX() != currLocation.getX() || teleportStart.getY() != currLocation.getY() || teleportStart.getZ() != currLocation.getZ()) diff --git a/src/main/java/com/samleighton/sethomestwo/datatypes/PersistentHome.java b/src/main/java/com/samleighton/sethomestwo/datatypes/PersistentHome.java index 545f9d3..fe18d2a 100644 --- a/src/main/java/com/samleighton/sethomestwo/datatypes/PersistentHome.java +++ b/src/main/java/com/samleighton/sethomestwo/datatypes/PersistentHome.java @@ -2,6 +2,7 @@ import com.samleighton.sethomestwo.models.Home; import org.apache.commons.lang3.SerializationUtils; +import com.samleighton.sethomestwo.metrics.Errors; import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.Material; @@ -39,6 +40,7 @@ public class PersistentHome implements PersistentDataType { return (Home) ois.readObject(); } catch (IOException | ClassNotFoundException e) { Bukkit.getLogger().severe("There was a problem deserializing a home."); + Errors.count(Errors.ITEM_DATA); e.printStackTrace(); } diff --git a/src/main/java/com/samleighton/sethomestwo/datatypes/PersistentString.java b/src/main/java/com/samleighton/sethomestwo/datatypes/PersistentString.java index 7ba29b9..08ad6fb 100644 --- a/src/main/java/com/samleighton/sethomestwo/datatypes/PersistentString.java +++ b/src/main/java/com/samleighton/sethomestwo/datatypes/PersistentString.java @@ -1,6 +1,7 @@ package com.samleighton.sethomestwo.datatypes; import org.apache.commons.lang3.SerializationUtils; +import com.samleighton.sethomestwo.metrics.Errors; import org.bukkit.Bukkit; import org.bukkit.persistence.PersistentDataAdapterContext; import org.bukkit.persistence.PersistentDataType; @@ -37,6 +38,7 @@ public class PersistentString implements PersistentDataType { return (String) ois.readObject(); } catch (IOException | ClassNotFoundException e) { Bukkit.getLogger().severe("There was an issue deserializing a string."); + Errors.count(Errors.ITEM_DATA); e.printStackTrace(); } diff --git a/src/main/java/com/samleighton/sethomestwo/enums/UserError.java b/src/main/java/com/samleighton/sethomestwo/enums/UserError.java index a696e52..3812d6f 100644 --- a/src/main/java/com/samleighton/sethomestwo/enums/UserError.java +++ b/src/main/java/com/samleighton/sethomestwo/enums/UserError.java @@ -5,24 +5,23 @@ public enum UserError { INVALID_HOME_ITEM("This home item does not belong to you."), /** Max Home restriction */ - SET_MAX_HOMES_SINGULAR("Max Homes Type is singular. Usage: /set-max-homes [max number of homes]"), - SET_MAX_HOMES_GROUPS("Max Homes Type is groups. Usage: /set-max-homes [group name] [max number of homes]"), + SET_MAX_HOMES_SINGULAR("Max Homes Type is singular. Usage: /%s [max number of homes]"), + SET_MAX_HOMES_GROUPS("Max Homes Type is groups. Usage: /%s [group name] [max number of homes]"), MAX_HOMES("You have reached the maximum number of homes allowed."), /** Teleport restriction */ TELEPORT_IS_BLACKLISTED("You cannot teleport to this home because the dimension it is in has been blacklisted."), DIMENSION_IS_BLACKLISTED("You cannot set a home in this dimension because it has been blacklisted."), - MOVED_WHILE_TELEPORTING("Your teleport has been cancelled because you have moved."), + MOVED_WHILE_TELEPORTING("Your teleport has been canceled because you have moved."), ALREADY_TELEPORTING("You cannot teleport while already teleporting."), - UNSAFE_HOME("Teleport cancelled: this home is not safe to stand in and no safe spot was found nearby."), + UNSAFE_HOME("Teleport canceled: this home is not safe to stand in and no safe spot was found nearby."), /** Command Input Errors */ DIMENSION_IS_NOT_BLACKLISTED("The %s dimension has not been blacklisted yet therefore you cannot remove it."), - INVALID_DIMENSION("%s is not a valid dimension. Valid dimensions are (nether, overworld, end)."), - DELETE_HOME_USAGE("Usage: /delete-home [name]"), + INVALID_WORLD("%s is not a valid world. This server's worlds are: %s"), + DELETE_HOME_USAGE("Usage: /%s [name]"), INVALID_MATERIAL("The material you entered is not valid, please try a different one."), - PLAYER_NOT_ONLINE("The player supplied is either not online or does not exist."), - NO_HOMES("You have not created any homes yet. Use /create-home."), + PLAYER_NOT_FOUND("No player by that name is online or has any saved homes."), PLAYERS_ONLY("Only players may execute this command."), DIMENSION_ALREADY_BLACKLISTED("The %s dimension has already been blacklisted. You cannot add it again."), GROUP_DOES_NOT_EXIST("Group does not exist. Use /get-max-homes-groups to see all groups."), diff --git a/src/main/java/com/samleighton/sethomestwo/enums/UserInfo.java b/src/main/java/com/samleighton/sethomestwo/enums/UserInfo.java index 9b5705c..29e584e 100644 --- a/src/main/java/com/samleighton/sethomestwo/enums/UserInfo.java +++ b/src/main/java/com/samleighton/sethomestwo/enums/UserInfo.java @@ -1,15 +1,18 @@ package com.samleighton.sethomestwo.enums; public enum UserInfo { - GET_PLAYER_HOMES_USAGE("Usage: /get-player-homes [playerName]"), - GET_BLACKLIST_USAGE("Usage: /get-blacklisted-dimensions"), - REMOVE_FROM_BLACKLIST_USAGE("Usage: /remove-from-blacklist [dimension names]"), - ADD_TO_BLACKLIST_USAGE("Usage: /add-to-blacklist [dimension names]"), - CREATE_HOME_USAGE("Usage: /create-home [name] [display_material | d | default] [description]"), - NO_HOMES("You have not setup any homes yet, you can use the /create-home command to create one."), + GET_PLAYER_HOMES_USAGE("Usage: /%s [playerName]"), + BLACKLIST_USAGE("Usage: /%s [world]"), + CREATE_HOME_USAGE("Usage: /create-home [name] [icon material, or d for the default icon] [description]. Omit the name and the home is called 'default'."), + NO_HOMES("You have not created any homes yet. Use /create-home to make your first one."), NO_MAX_HOMES("There is no max number of homes."), NO_BLACKLISTED_DIMENSIONS("No dimensions are blacklisted"), - MOVED_TO_SAFE_SPOT("Your home was not safe to stand in, so you were moved to the nearest safe spot."); + MOVED_TO_SAFE_SPOT("Your home was not safe to stand in, so you were moved to the nearest safe spot."), + MOVE_HOME_USAGE("Usage: /%s "), + GO_PLAYER_HOME_USAGE("Usage: /%s "), + DELETE_PLAYER_HOME_USAGE("Usage: /%s "), + MOVE_PLAYER_HOME_USAGE("Usage: /%s "), + V1_IMPORT_PENDING("Set Homes v1 has %s home(s) waiting to be imported. Run /import-homes sethomes for a preview, then add confirm to bring them across."); private final String value; diff --git a/src/main/java/com/samleighton/sethomestwo/enums/UserSuccess.java b/src/main/java/com/samleighton/sethomestwo/enums/UserSuccess.java index 796b2ca..50b5f16 100644 --- a/src/main/java/com/samleighton/sethomestwo/enums/UserSuccess.java +++ b/src/main/java/com/samleighton/sethomestwo/enums/UserSuccess.java @@ -1,7 +1,7 @@ package com.samleighton.sethomestwo.enums; public enum UserSuccess { - HOME_CREATED("%s has been created successfully."), + HOME_CREATED("%s has been created successfully. Icon: %s"), HOME_DELETED("%s has been deleted successfully."), HOME_MOVED("%s has been moved to your current location."), HOME_ICON_CHANGED("The icon for %s is now %s."), @@ -9,7 +9,9 @@ public enum UserSuccess { TELEPORTED("Teleported to %s"), DIMENSION_ADDED_TO_BLACKLIST("%s has been added to the blacklist"), DIMENSION_REMOVED_FROM_BLACKLIST("%s has been removed from the blacklist"), - MAX_HOMES_UPDATED_SUCCESSFULLY("Max homes updated successfully."); + MAX_HOMES_UPDATED_SUCCESSFULLY("Max homes updated successfully."), + PLAYER_HOME_DELETED("%s's home '%s' has been deleted."), + PLAYER_HOME_MOVED("%s's home '%s' has been moved to your location."); private final String value; diff --git a/src/main/java/com/samleighton/sethomestwo/events/PlayerJoin.java b/src/main/java/com/samleighton/sethomestwo/events/PlayerJoin.java index 2f936f9..f973bf7 100644 --- a/src/main/java/com/samleighton/sethomestwo/events/PlayerJoin.java +++ b/src/main/java/com/samleighton/sethomestwo/events/PlayerJoin.java @@ -1,8 +1,13 @@ package com.samleighton.sethomestwo.events; import com.samleighton.sethomestwo.SetHomesTwo; +import com.samleighton.sethomestwo.dao.HomesDao; +import com.samleighton.sethomestwo.enums.UserInfo; import com.samleighton.sethomestwo.gui.GuiSession; import com.samleighton.sethomestwo.gui.HomesGui; +import com.samleighton.sethomestwo.importers.PendingV1Import; +import com.samleighton.sethomestwo.utils.ChatUtils; +import com.samleighton.sethomestwo.utils.ConfigUtil; import com.samleighton.sethomestwo.updates.UpdateChecker; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -24,6 +29,23 @@ public void onPlayerJoin(PlayerJoinEvent event){ Player player = event.getPlayer(); plugin.getGuiSessionMap().put(player.getUniqueId(), new GuiSession(new HomesGui(player))); + new HomesDao().refreshPlayerName(player.getUniqueId(), player.getName()); + updateChecker.notifyIfUpdateAvailable(player); + notifyPendingV1Import(player); + } + + /** + * Tells an admin that v1 homes are waiting. The permission is checked first + * so an ordinary join never queries the database or reads v1's file. + */ + private void notifyPendingV1Import(Player player) { + if (!player.hasPermission("sh2.import-homes")) return; + + int waiting = PendingV1Import.waitingToBeImported(); + if (waiting == 0) return; + + ChatUtils.sendInfo(player, String.format(ConfigUtil.getConfig().getString( + "v1ImportPending", UserInfo.V1_IMPORT_PENDING.getValue()), waiting)); } } diff --git a/src/main/java/com/samleighton/sethomestwo/events/RightClickHomeItem.java b/src/main/java/com/samleighton/sethomestwo/events/RightClickHomeItem.java index 99b0903..5cd67f9 100644 --- a/src/main/java/com/samleighton/sethomestwo/events/RightClickHomeItem.java +++ b/src/main/java/com/samleighton/sethomestwo/events/RightClickHomeItem.java @@ -71,7 +71,7 @@ public void onPlayerRightClickHomeItem(PlayerInteractEvent event) { return; } - Dao homesDao = new HomesDao(); + Dao homesDao = new HomesDao(player.hasPermission("sh2.bypass-blacklist")); List playersHomes = homesDao.getAll(player.getUniqueId()); GuiSession session = plugin.getGuiSessionMap().get(player.getUniqueId()); diff --git a/src/main/java/com/samleighton/sethomestwo/gui/HomeActionsGui.java b/src/main/java/com/samleighton/sethomestwo/gui/HomeActionsGui.java index 98f1653..31bcdee 100644 --- a/src/main/java/com/samleighton/sethomestwo/gui/HomeActionsGui.java +++ b/src/main/java/com/samleighton/sethomestwo/gui/HomeActionsGui.java @@ -5,6 +5,7 @@ import com.samleighton.sethomestwo.datatypes.PersistentString; import com.samleighton.sethomestwo.enums.UserError; import com.samleighton.sethomestwo.enums.UserSuccess; +import com.samleighton.sethomestwo.metrics.UsageCounters; import com.samleighton.sethomestwo.models.Home; import com.samleighton.sethomestwo.utils.ChatUtils; import com.samleighton.sethomestwo.utils.ConfigUtil; @@ -25,6 +26,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.Objects; +import java.util.Set; /** * The management submenu for a single home. Addressed by home id so a rename @@ -42,6 +44,8 @@ public class HomeActionsGui implements GuiScreen { public static final String ACTION_CANCEL_DELETE = "cancel-delete"; public static final String ACTION_BACK = "back"; + private static final Set KNOWN_ACTIONS = Set.of(ACTION_RENAME, ACTION_MOVE, ACTION_ICON, ACTION_DELETE, ACTION_CONFIRM_DELETE, ACTION_CANCEL_DELETE, ACTION_BACK); + private static final int SLOT_RENAME = 0; private static final int SLOT_MOVE = 1; private static final int SLOT_ICON = 2; @@ -63,6 +67,17 @@ enum RenameOutcome { RENAMED } + /** + * Why a move ended the way it did. Public so the move-home command, which + * lives in a different package, can share the rule the GUI button applies. + */ + public enum MoveOutcome { + GONE, + BLACKLISTED, + UPDATE_FAILED, + MOVED + } + private final Inventory inv; private final int homeId; private boolean confirmingDelete = false; @@ -153,6 +168,8 @@ public void onClick(InventoryClickEvent event, GuiSession session) { if (action == null) return; + if (KNOWN_ACTIONS.contains(action)) SetHomesTwo.instance().getUsageCounters().increment(UsageCounters.Family.GUI_ACTION, action); + if (ACTION_BACK.equals(action)) { session.openHomeList(player); return; @@ -162,33 +179,21 @@ public void onClick(InventoryClickEvent event, GuiSession session) { Home fresh = reloadHome(player, session); if (fresh == null) return; - Location destination = player.getLocation(); - String destinationDimension = Objects.requireNonNull(destination.getWorld()).getEnvironment().toString(); - - // Blacklisted dimension guard, sharing the rule create-home applies. - if (ServerUtil.isDimensionBlacklisted(destinationDimension)) { - ChatUtils.sendError(player, ConfigUtil.getConfig().getString("cannotMoveToBlacklistedDimension", UserError.CANNOT_MOVE_TO_BLACKLISTED_DIMENSION.getValue())); - return; - } - - fresh.setWorld(destination.getWorld().getUID().toString()); - fresh.setX(destination.getX()); - fresh.setY(destination.getY()); - fresh.setZ(destination.getZ()); - fresh.setPitch(destination.getPitch()); - fresh.setYaw(destination.getYaw()); - fresh.setDimension(destinationDimension); - - HomesDao homesDao = new HomesDao(); - if (!homesDao.update(fresh)) { - ChatUtils.pluginError(player); - return; + switch (applyMove(player, fresh)) { + case BLACKLISTED: + ChatUtils.sendError(player, ConfigUtil.getConfig().getString("cannotMoveToBlacklistedDimension", UserError.CANNOT_MOVE_TO_BLACKLISTED_DIMENSION.getValue())); + return; + case UPDATE_FAILED: + ChatUtils.pluginError(player); + return; + case MOVED: + String moved = ConfigUtil.getConfig().getString("homeMoved", UserSuccess.HOME_MOVED.getValue()); + ChatUtils.sendSuccess(player, String.format(moved, fresh.getName())); + returnToRefreshedList(player, session); + return; + default: + return; } - - String moved = ConfigUtil.getConfig().getString("homeMoved", UserSuccess.HOME_MOVED.getValue()); - ChatUtils.sendSuccess(player, String.format(moved, fresh.getName())); - returnToRefreshedList(player, session); - return; } if (ACTION_ICON.equals(action)) { @@ -298,6 +303,28 @@ RenameOutcome applyRename(Player player, Home home, String rawName, int maxLengt return RenameOutcome.RENAMED; } + /** + * Moves a home to the player's current location, holding only the mutation: + * no messaging, no navigation. Those stay with each caller. + */ + public static MoveOutcome applyMove(Player player, Home home) { + if (home == null) return MoveOutcome.GONE; + + Location destination = player.getLocation(); + if (!player.hasPermission("sh2.bypass-blacklist") && ServerUtil.isWorldBlacklisted(destination.getWorld())) + return MoveOutcome.BLACKLISTED; + + home.setWorld(Objects.requireNonNull(destination.getWorld()).getUID().toString()); + home.setX(destination.getX()); + home.setY(destination.getY()); + home.setZ(destination.getZ()); + home.setPitch(destination.getPitch()); + home.setYaw(destination.getYaw()); + home.setDimension(destination.getWorld().getEnvironment().toString()); + + return new HomesDao().update(home) ? MoveOutcome.MOVED : MoveOutcome.UPDATE_FAILED; + } + /** * Open an anvil prompt for the new home name. Validation failures re-prompt * with the reason rather than closing. @@ -368,7 +395,7 @@ private Home reloadHome(Player player, GuiSession session) { */ private void returnToRefreshedList(Player player, GuiSession session) { player.closeInventory(); - HomesDao homesDao = new HomesDao(); + HomesDao homesDao = new HomesDao(player.hasPermission("sh2.bypass-blacklist")); session.getHomesGui().setHomes(homesDao.getAll(player.getUniqueId())); session.openHomeList(player); } diff --git a/src/main/java/com/samleighton/sethomestwo/gui/HomesGui.java b/src/main/java/com/samleighton/sethomestwo/gui/HomesGui.java index 920c8ee..d365ea5 100644 --- a/src/main/java/com/samleighton/sethomestwo/gui/HomesGui.java +++ b/src/main/java/com/samleighton/sethomestwo/gui/HomesGui.java @@ -4,6 +4,8 @@ import com.samleighton.sethomestwo.dao.HomesDao; import com.samleighton.sethomestwo.datatypes.PersistentHome; import com.samleighton.sethomestwo.enums.UserError; +import com.samleighton.sethomestwo.enums.UserInfo; +import com.samleighton.sethomestwo.metrics.UsageCounters; import com.samleighton.sethomestwo.models.Home; import com.samleighton.sethomestwo.utils.ChatUtils; import com.samleighton.sethomestwo.utils.ConfigUtil; @@ -93,8 +95,7 @@ public void displayInventory(Player player) { if (homesForDisplay == null || homesForDisplay.isEmpty()) { player.closeInventory(); - String noHomesError = ConfigUtil.getConfig().getString("noHomes", UserError.NO_HOMES.getValue()); - ChatUtils.sendError(player, noHomesError); + ChatUtils.sendInfo(player, ConfigUtil.getConfig().getString("noHomes", UserInfo.NO_HOMES.getValue())); return; } @@ -201,11 +202,19 @@ public void onClick(InventoryClickEvent event, GuiSession session) { if (!(clickedItem.getType().equals(backPageMaterial) || clickedItem.getType().equals(nextPageMaterial))) return; + UsageCounters counters = SetHomesTwo.instance().getUsageCounters(); + // Move to prev page - if (clickedItem.getType().equals(backPageMaterial)) currentPage--; + if (clickedItem.getType().equals(backPageMaterial)) { + currentPage--; + counters.increment(UsageCounters.Family.GUI_ACTION, UsageCounters.GUI_PAGE_PREVIOUS); + } // Move to next page - if (clickedItem.getType().equals(nextPageMaterial)) currentPage++; + if (clickedItem.getType().equals(nextPageMaterial)) { + currentPage++; + counters.increment(UsageCounters.Family.GUI_ACTION, UsageCounters.GUI_PAGE_NEXT); + } // Display new inv state to player this.displayInventory(player); @@ -222,7 +231,7 @@ public void onClick(InventoryClickEvent event, GuiSession session) { // Right-click opens management, left-click teleports. Management is only // offered on the viewer's own list; the admin view of another player's - // homes falls through to teleport behaviour on any click. + // homes falls through to teleport behavior on any click. if (event.isRightClick() && isOwnList) { if (!player.hasPermission("sh2.manage-homes")) return; @@ -241,6 +250,8 @@ public void onClick(InventoryClickEvent event, GuiSession session) { player.closeInventory(); + SetHomesTwo.instance().getUsageCounters().increment(UsageCounters.Family.GUI_ACTION, UsageCounters.GUI_TELEPORT); + // Teleport player to home home.teleport(player); } diff --git a/src/main/java/com/samleighton/sethomestwo/importers/EssentialsXImporter.java b/src/main/java/com/samleighton/sethomestwo/importers/EssentialsXImporter.java index 121f03c..7b7fcab 100644 --- a/src/main/java/com/samleighton/sethomestwo/importers/EssentialsXImporter.java +++ b/src/main/java/com/samleighton/sethomestwo/importers/EssentialsXImporter.java @@ -45,15 +45,19 @@ public ImportReport run(boolean dryRun) { ConfigurationSection homes = userData.getConfigurationSection("homes"); if (homes == null) continue; + // EssentialsX user files name by UUID and carry the last known + // account name directly, unlike v1's homes.yml, which has none. + String playerName = userData.getString("lastAccountName"); + for (String homeName : homes.getKeys(false)) { - importOne(homesDao, report, homes.getConfigurationSection(homeName), playerUUID, homeName, dryRun); + importOne(homesDao, report, homes.getConfigurationSection(homeName), playerUUID, playerName, homeName, dryRun); } } return report; } - private void importOne(HomesDao homesDao, ImportReport report, ConfigurationSection home, String playerUUID, String homeName, boolean dryRun) { + private void importOne(HomesDao homesDao, ImportReport report, ConfigurationSection home, String playerUUID, String playerName, String homeName, boolean dryRun) { try { if (home == null) { report.failed++; @@ -81,15 +85,20 @@ private void importOne(HomesDao homesDao, ImportReport report, ConfigurationSect (float) home.getDouble("pitch") ); + if (playerName != null) report.namesResolved++; + if (!dryRun) { - boolean saved = homesDao.save(new Home( + Home importedHome = new Home( playerUUID, HomesImporter.defaultMaterial(), location, homeName, null, world.getEnvironment().toString() - )); + ); + importedHome.setPlayerName(playerName); + + boolean saved = homesDao.save(importedHome); if (!saved) { report.failed++; return; diff --git a/src/main/java/com/samleighton/sethomestwo/importers/HomesImporter.java b/src/main/java/com/samleighton/sethomestwo/importers/HomesImporter.java index bbd92e5..26d7dd6 100644 --- a/src/main/java/com/samleighton/sethomestwo/importers/HomesImporter.java +++ b/src/main/java/com/samleighton/sethomestwo/importers/HomesImporter.java @@ -1,7 +1,13 @@ package com.samleighton.sethomestwo.importers; import com.samleighton.sethomestwo.utils.ConfigUtil; +import org.bukkit.Bukkit; import org.bukkit.Material; +import org.bukkit.OfflinePlayer; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; public interface HomesImporter { @@ -15,4 +21,17 @@ static String defaultMaterial() { Material material = Material.matchMaterial(ConfigUtil.getConfig().getString("defaultHomeItem", "white_wool")); return material == null ? Material.WHITE_WOOL.name() : material.name(); } + + /** + * Every name this server has cached, keyed by UUID. Snapshotted once per + * import because {@link Bukkit#getOfflinePlayers()} scans the playerdata + * directory on every call. + */ + static Map cachedNames() { + Map names = new HashMap<>(); + for (OfflinePlayer candidate : Bukkit.getOfflinePlayers()) { + names.put(candidate.getUniqueId(), candidate.getName()); + } + return names; + } } diff --git a/src/main/java/com/samleighton/sethomestwo/importers/ImportReport.java b/src/main/java/com/samleighton/sethomestwo/importers/ImportReport.java index 0aa9213..3ecbb17 100644 --- a/src/main/java/com/samleighton/sethomestwo/importers/ImportReport.java +++ b/src/main/java/com/samleighton/sethomestwo/importers/ImportReport.java @@ -5,16 +5,37 @@ public class ImportReport { public int imported = 0; + public int renamed = 0; public int skippedExisting = 0; public int skippedWorldMissing = 0; public int failed = 0; + public int blacklistImported = 0; + public int blacklistSkippedExisting = 0; + public int namesResolved = 0; public final List warnings = new ArrayList<>(); + public final List configNotes = new ArrayList<>(); public String summary(boolean dryRun) { String verb = dryRun ? "Would import" : "Imported"; + String base = String.format( + "%s %d homes (%d renamed to avoid a name clash, %d skipped: already imported, %d skipped: world missing, %d failed).", + verb, imported, renamed, skippedExisting, skippedWorldMissing, failed + ); + if (namesResolved > 0) { + base += String.format(" %d home(s) matched an owner name.", namesResolved); + } + return base; + } + + public boolean hasBlacklistActivity() { + return blacklistImported > 0 || blacklistSkippedExisting > 0; + } + + public String blacklistSummary(boolean dryRun) { + String verb = dryRun ? "Would add" : "Added"; return String.format( - "%s %d homes (%d skipped: name exists, %d skipped: world missing, %d failed).", - verb, imported, skippedExisting, skippedWorldMissing, failed + "%s %d world(s) to the blacklist (%d already blacklisted).", + verb, blacklistImported, blacklistSkippedExisting ); } } diff --git a/src/main/java/com/samleighton/sethomestwo/importers/PendingV1Import.java b/src/main/java/com/samleighton/sethomestwo/importers/PendingV1Import.java new file mode 100644 index 0000000..cbf0239 --- /dev/null +++ b/src/main/java/com/samleighton/sethomestwo/importers/PendingV1Import.java @@ -0,0 +1,54 @@ +package com.samleighton.sethomestwo.importers; + +import com.samleighton.sethomestwo.SetHomesTwo; +import com.samleighton.sethomestwo.dao.HomesDao; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; + +import java.io.File; + +/** + * Whether a Set Homes v1 homes.yml is sitting there waiting to be imported. + * Reads the same file and layout as {@link SetHomesV1Importer}. + */ +public final class PendingV1Import { + + public static final String SOURCE_PATH = "plugins/SetHomes/homes.yml"; + + private PendingV1Import() { + } + + /** + * Homes waiting in v1's file while our own database is still empty, or 0 + * when there is nothing to announce. The database is checked first, so a + * server that has already imported never touches the disk. + * + * @return the number of homes waiting, 0 if none or if we already hold homes + */ + public static int waitingToBeImported() { + if (new HomesDao().countAll() > 0) return 0; + + File homesFile = new File(SetHomesTwo.instance().getDataFolder().getParentFile(), "SetHomes/homes.yml"); + if (!homesFile.exists()) return 0; + + return countHomes(YamlConfiguration.loadConfiguration(homesFile)); + } + + private static int countHomes(YamlConfiguration source) { + int total = 0; + + ConfigurationSection allNamed = source.getConfigurationSection("allNamedHomes"); + if (allNamed != null) { + for (String uuid : allNamed.getKeys(false)) { + ConfigurationSection playerSection = allNamed.getConfigurationSection(uuid); + if (playerSection != null) total += playerSection.getKeys(false).size(); + } + } + + // One unnamed home per player, which the importer brings across as "default". + ConfigurationSection unknown = source.getConfigurationSection("unknownHomes"); + if (unknown != null) total += unknown.getKeys(false).size(); + + return total; + } +} diff --git a/src/main/java/com/samleighton/sethomestwo/importers/SetHomesV1Importer.java b/src/main/java/com/samleighton/sethomestwo/importers/SetHomesV1Importer.java index 691f332..22e0e51 100644 --- a/src/main/java/com/samleighton/sethomestwo/importers/SetHomesV1Importer.java +++ b/src/main/java/com/samleighton/sethomestwo/importers/SetHomesV1Importer.java @@ -1,15 +1,22 @@ package com.samleighton.sethomestwo.importers; import com.samleighton.sethomestwo.SetHomesTwo; +import com.samleighton.sethomestwo.dao.BlacklistDao; import com.samleighton.sethomestwo.dao.HomesDao; import com.samleighton.sethomestwo.models.Home; import org.bukkit.Bukkit; +import org.bukkit.ChatColor; import org.bukkit.Location; import org.bukkit.World; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; import java.io.File; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.UUID; public class SetHomesV1Importer implements HomesImporter { @@ -32,6 +39,8 @@ public ImportReport run(boolean dryRun) { YamlConfiguration source = YamlConfiguration.loadConfiguration(homesFile); HomesDao homesDao = new HomesDao(); + NameLedger ledger = new NameLedger(homesDao); + Map cachedNames = HomesImporter.cachedNames(); // Named homes: allNamedHomes...{world,x,y,z,pitch,yaw,desc} ConfigurationSection allNamed = source.getConfigurationSection("allNamedHomes"); @@ -40,7 +49,7 @@ public ImportReport run(boolean dryRun) { ConfigurationSection playerSection = allNamed.getConfigurationSection(uuid); if (playerSection == null) continue; for (String homeName : playerSection.getKeys(false)) { - importOne(homesDao, report, playerSection.getConfigurationSection(homeName), uuid, homeName, dryRun); + importOne(homesDao, ledger, report, playerSection.getConfigurationSection(homeName), uuid, homeName, cachedNames, dryRun); } } } @@ -49,14 +58,17 @@ public ImportReport run(boolean dryRun) { ConfigurationSection unknown = source.getConfigurationSection("unknownHomes"); if (unknown != null) { for (String uuid : unknown.getKeys(false)) { - importOne(homesDao, report, unknown.getConfigurationSection(uuid), uuid, "default", dryRun); + importOne(homesDao, ledger, report, unknown.getConfigurationSection(uuid), uuid, "default", cachedNames, dryRun); } } + importBlacklist(pluginsDir, report, dryRun); + reportConfig(pluginsDir, report); + return report; } - private void importOne(HomesDao homesDao, ImportReport report, ConfigurationSection home, String playerUUID, String homeName, boolean dryRun) { + private void importOne(HomesDao homesDao, NameLedger ledger, ImportReport report, ConfigurationSection home, String playerUUID, String homeName, Map cachedNames, boolean dryRun) { try { if (home == null) { report.failed++; @@ -70,11 +82,23 @@ private void importOne(HomesDao homesDao, ImportReport report, ConfigurationSect return; } - if (homesDao.get(UUID.fromString(playerUUID), homeName) != null) { + UUID owner = UUID.fromString(playerUUID); + + if (ledger.importedBefore(owner, homeName)) { report.skippedExisting++; return; } + String storedName = ledger.claim(owner, homeName); + if (!storedName.equals(homeName)) { + report.renamed++; + String note = String.format( + "Home '%s' for player %s differs only in capitalization from another of that player's homes, which Set Homes v1 allowed. It takes the name '%s' here, so both locations are kept.", + homeName, playerUUID, storedName); + report.warnings.add(note); + if (!dryRun) Bukkit.getLogger().warning(note); + } + Location location = new Location( world, home.getDouble("x"), @@ -84,15 +108,21 @@ private void importOne(HomesDao homesDao, ImportReport report, ConfigurationSect (float) home.getDouble("pitch") ); + String playerName = cachedNames.get(owner); + if (playerName != null) report.namesResolved++; + if (!dryRun) { - boolean saved = homesDao.save(new Home( + Home importedHome = new Home( playerUUID, HomesImporter.defaultMaterial(), location, - homeName, + storedName, home.getString("desc"), world.getEnvironment().toString() - )); + ); + importedHome.setPlayerName(playerName); + + boolean saved = homesDao.save(importedHome); if (!saved) { report.failed++; return; @@ -105,4 +135,151 @@ private void importOne(HomesDao homesDao, ImportReport report, ConfigurationSect report.warnings.add(String.format("Home '%s' for player %s could not be read: %s", homeName, playerUUID, e.getMessage())); } } + + // v1 allowed case-only duplicate names ('base' and 'Base'); v2 does not, so + // the second one is stored as 'Base2' instead of dropped. Names taken this + // run are tracked in memory so a dry run reports the same as the confirm. + private static final class NameLedger { + private final HomesDao homesDao; + private final Map> beforeThisRun = new HashMap<>(); + private final Map> takenThisRun = new HashMap<>(); + + private NameLedger(HomesDao homesDao) { + this.homesDao = homesDao; + } + + // True when the player had a home of this name before the run started. + private boolean importedBefore(UUID player, String name) { + return namesBeforeThisRun(player).contains(name.toLowerCase()); + } + + // Returns the name asked for, or the first free numbered variant of it. + private String claim(UUID player, String wanted) { + String candidate = wanted; + + for (int suffix = 2; isTaken(player, candidate); suffix++) { + candidate = wanted + suffix; + } + + takenThisRun.computeIfAbsent(player, p -> new HashSet<>()).add(candidate.toLowerCase()); + return candidate; + } + + private boolean isTaken(UUID player, String name) { + String lowered = name.toLowerCase(); + return namesBeforeThisRun(player).contains(lowered) + || takenThisRun.getOrDefault(player, Set.of()).contains(lowered); + } + + private Set namesBeforeThisRun(UUID player) { + return beforeThisRun.computeIfAbsent(player, p -> { + Set names = new HashSet<>(); + for (String name : homesDao.namesFor(p)) { + names.add(name.toLowerCase()); + } + return names; + }); + } + } + + /** + * v1's world_blacklist.yml holds a flat blacklisted_worlds list. Missing or + * empty is normal, not an error. A world absent from this server is still + * stored, but warned about: /blacklist remove validates against the + * server's current worlds, so an absent one cannot be removed by command + * until that world exists. + */ + private void importBlacklist(File pluginsDir, ImportReport report, boolean dryRun) { + File blacklistFile = new File(pluginsDir, "SetHomes/world_blacklist.yml"); + if (!blacklistFile.exists()) return; + + YamlConfiguration source = YamlConfiguration.loadConfiguration(blacklistFile); + List worlds = source.getStringList("blacklisted_worlds"); + if (worlds.isEmpty()) return; + + BlacklistDao blacklistDao = new BlacklistDao(); + List existing = blacklistDao.getAll(); + + for (String world : worlds) { + String lowered = world.toLowerCase(); + + if (existing.contains(lowered)) { + report.blacklistSkippedExisting++; + continue; + } + + if (Bukkit.getWorld(lowered) == null) { + report.warnings.add(String.format( + "Blacklisted world '%s' does not exist on this server. It will still be stored, but /remove-from-blacklist will refuse it until that world exists.", + lowered)); + } + + if (!dryRun) { + boolean saved = blacklistDao.save(lowered); + if (!saved) { + report.warnings.add(String.format("Blacklisted world '%s' could not be saved.", lowered)); + continue; + } + } + + // Tracked locally too, so a world repeated in the source file is + // only ever counted (and written) once per run. + existing.add(lowered); + report.blacklistImported++; + } + } + + /** + * Reports the v1 settings that have an equivalent here, and the key to put + * each one under. Never writes config.yml. + */ + private void reportConfig(File pluginsDir, ImportReport report) { + File configFile = new File(pluginsDir, "SetHomes/config.yml"); + if (!configFile.exists()) return; + + YamlConfiguration v1 = YamlConfiguration.loadConfiguration(configFile); + + if (v1.isSet("tp-delay")) { + report.configNotes.add(String.format("v1 tp-delay: %s -> set delay: %s in config.yml", v1.get("tp-delay"), v1.get("tp-delay"))); + } + + if (v1.isSet("tp-cancelOnMove")) { + report.configNotes.add(String.format("v1 tp-cancelOnMove: %s -> set cancelOnMove: %s in config.yml", v1.get("tp-cancelOnMove"), v1.get("tp-cancelOnMove"))); + } + + if (v1.isSet("max-homes-msg")) { + report.configNotes.add(messageNote("max-homes-msg", "maxHomesReached", v1.getString("max-homes-msg"))); + } + + if (v1.isSet("tp-cancelOnMove-msg")) { + report.configNotes.add(messageNote("tp-cancelOnMove-msg", "movedWhileTeleporting", v1.getString("tp-cancelOnMove-msg"))); + } + + ConfigurationSection maxHomes = v1.getConfigurationSection("max-homes"); + if (maxHomes != null && !maxHomes.getKeys(false).isEmpty()) { + report.configNotes.add("v1 max-homes -> set maxHomesType: groups and maxHomeEnabled: true in config.yml, then set maxHomes. to:"); + for (String group : maxHomes.getKeys(false)) { + int limit = maxHomes.getInt(group); + String value = limit == 0 + ? "unlimited (v1 treats 0 as unlimited; leave this group out of maxHomes rather than setting it to 0)" + : String.valueOf(limit); + report.configNotes.add(String.format(" maxHomes.%s: %s (v1 max-homes.%s was %d)", group, value, group, limit)); + } + } + + if (v1.isSet("tp-cooldown")) { + report.configNotes.add(String.format("v1 tp-cooldown: %s has no v2 equivalent; teleport cooldown is not supported.", v1.get("tp-cooldown"))); + } + } + + // A v1 message may carry section-sign color codes, which chat would apply + // to the rest of the line. Show them as & so the note stays legible. + private static String messageNote(String v1Key, String v2Key, String value) { + String shown = value.replace(ChatColor.COLOR_CHAR, '&'); + String note = String.format("v1 %s -> set %s: '%s' in config.yml", v1Key, v2Key, shown); + if (!shown.equals(value)) { + note += " (color codes shown as &; copy the original from plugins/SetHomes/config.yml to keep them)"; + } + return note; + } } diff --git a/src/main/java/com/samleighton/sethomestwo/metrics/Buckets.java b/src/main/java/com/samleighton/sethomestwo/metrics/Buckets.java new file mode 100644 index 0000000..fdfbaab --- /dev/null +++ b/src/main/java/com/samleighton/sethomestwo/metrics/Buckets.java @@ -0,0 +1,37 @@ +package com.samleighton.sethomestwo.metrics; + +/** + * Bucket labels for the bStats scale and config pies. Ranges are what the + * dashboard shows, so change them only with a matching change to the README. + */ +public final class Buckets { + + private Buckets() {} + + public static String delay(int seconds) { + if (seconds <= 0) return "0"; + if (seconds <= 3) return "1-3"; + if (seconds <= 10) return "4-10"; + return "10+"; + } + + public static String homesPerServer(int total) { + if (total <= 0) return "0"; + if (total <= 50) return "1-50"; + if (total <= 500) return "51-500"; + if (total <= 5000) return "501-5000"; + return "5000+"; + } + + /** + * Average homes per player with at least one home, rounded down. + */ + public static String homesPerPlayer(int total, int players) { + if (total <= 0 || players <= 0) return "0"; + int average = total / players; + if (average <= 1) return "1"; + if (average <= 3) return "2-3"; + if (average <= 10) return "4-10"; + return "10+"; + } +} diff --git a/src/main/java/com/samleighton/sethomestwo/metrics/CommandUsageListener.java b/src/main/java/com/samleighton/sethomestwo/metrics/CommandUsageListener.java new file mode 100644 index 0000000..f2dafd7 --- /dev/null +++ b/src/main/java/com/samleighton/sethomestwo/metrics/CommandUsageListener.java @@ -0,0 +1,69 @@ +package com.samleighton.sethomestwo.metrics; + +import com.samleighton.sethomestwo.SetHomesTwo; +import org.bukkit.Bukkit; +import org.bukkit.command.PluginCommand; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import org.bukkit.event.server.ServerCommandEvent; + +import java.util.Locale; + +/** + * Counts every SetHomesTwo command typed by a player or the console, by + * canonical name and by the label actually typed. Commands owned by other + * plugins are ignored. Never cancels or alters the event. + */ +public class CommandUsageListener implements Listener { + + private final SetHomesTwo plugin; + + public CommandUsageListener(SetHomesTwo plugin) { + this.plugin = plugin; + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerCommand(PlayerCommandPreprocessEvent event) { + count(event.getMessage()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onConsoleCommand(ServerCommandEvent event) { + count(event.getCommand()); + } + + /** + * Counts the command on the given line if it belongs to this plugin. + * Swallows runtime failures: a metrics failure must never abort a command. + */ + void count(String commandLine) { + try { + String label = firstToken(commandLine); + if (label == null) return; + + PluginCommand command = Bukkit.getPluginCommand(label); + if (command == null || command.getPlugin() != plugin) return; + + String namespace = plugin.getName().toLowerCase(Locale.ROOT) + ":"; + String typed = label.startsWith(namespace) ? label.substring(namespace.length()) : label; + + UsageCounters counters = plugin.getUsageCounters(); + counters.increment(UsageCounters.Family.COMMAND, command.getName()); + counters.increment(UsageCounters.Family.ALIAS, typed); + } catch (RuntimeException ignored) { + // Counting is best effort. + } + } + + private static String firstToken(String commandLine) { + if (commandLine == null) return null; + String trimmed = commandLine.trim(); + if (trimmed.startsWith("/")) trimmed = trimmed.substring(1); + if (trimmed.isEmpty()) return null; + int space = trimmed.indexOf(' '); + String label = space < 0 ? trimmed : trimmed.substring(0, space); + return label.toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/com/samleighton/sethomestwo/metrics/Errors.java b/src/main/java/com/samleighton/sethomestwo/metrics/Errors.java new file mode 100644 index 0000000..3729e5f --- /dev/null +++ b/src/main/java/com/samleighton/sethomestwo/metrics/Errors.java @@ -0,0 +1,26 @@ +package com.samleighton.sethomestwo.metrics; + +import com.samleighton.sethomestwo.SetHomesTwo; + +/** + * Counts plugin failures by category for the bStats errors chart. Categories + * only, never messages, so nothing server-specific leaves the server. + */ +public final class Errors { + + public static final String SQL_WRITE = "sql-write"; + public static final String SQL_READ = "sql-read"; + public static final String DB_CONNECT = "db-connect"; + public static final String ITEM_DATA = "item-data"; + + private Errors() {} + + /** + * Safe to call from anywhere the plugin logs a failure; a null plugin + * (before it is registered) is ignored. + */ + public static void count(String category) { + SetHomesTwo plugin = SetHomesTwo.instance(); + if (plugin != null) plugin.getUsageCounters().increment(UsageCounters.Family.ERROR, category); + } +} diff --git a/src/main/java/com/samleighton/sethomestwo/metrics/MetricsReporter.java b/src/main/java/com/samleighton/sethomestwo/metrics/MetricsReporter.java new file mode 100644 index 0000000..4a44562 --- /dev/null +++ b/src/main/java/com/samleighton/sethomestwo/metrics/MetricsReporter.java @@ -0,0 +1,224 @@ +package com.samleighton.sethomestwo.metrics; + +import com.samleighton.sethomestwo.SetHomesTwo; +import com.samleighton.sethomestwo.dao.HomesDao; +import com.samleighton.sethomestwo.enums.DebugLevel; +import com.samleighton.sethomestwo.utils.ConfigUtil; +import org.bstats.bukkit.Metrics; +import org.bstats.charts.AdvancedBarChart; +import org.bstats.charts.AdvancedPie; +import org.bstats.charts.SingleLineChart; +import org.bstats.charts.SimplePie; +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.BooleanSupplier; +import java.util.function.Function; + +/** + * Sends anonymous usage counts to bStats. The only class that touches + * org.bstats; everything else increments {@link UsageCounters}. + */ +public class MetricsReporter { + + /** bStats service id for SetHomesTwo, from bstats.org. The reporter stays off while this is 0. */ + public static final int PLUGIN_ID = 33420; + + /** Keeps bStats off the boot path, same as the update check. */ + public static final long STARTUP_DELAY_TICKS = 100L; + + /** + * Developer switch: start the server with -Dsethomestwo.metrics.disabled=true + * and nothing is reported. For test and end-to-end servers, so their traffic + * never reaches the public dashboard. Deliberately not a config.yml key. + */ + public static final String DISABLE_PROPERTY = "sethomestwo.metrics.disabled"; + + private final SetHomesTwo plugin; + private final int pluginId; + private final BooleanSupplier enabled; + private final Function factory; + + private AutoCloseable running; + + public MetricsReporter(SetHomesTwo plugin) { + this(plugin, + PLUGIN_ID, + () -> shouldReport(plugin.getDataFolder().getParentFile()), + counters -> new BStatsHandle(plugin, counters)); + } + + /** + * Whether a live server should report: not switched off by the developer + * property, and not disabled through bStats' own server-wide config. + */ + static boolean shouldReport(File pluginsDir) { + if (Boolean.getBoolean(DISABLE_PROPERTY)) { + Bukkit.getLogger().info("SetHomesTwo metrics are off: " + DISABLE_PROPERTY + " is set."); + return false; + } + return bStatsEnabledGlobally(pluginsDir); + } + + /** + * bStats' server-wide switch, plugins/bStats/config.yml. Metrics has no + * per-plugin toggle, so this is the one owner-facing opt-out and it is + * honoured before bStats is built at all. A missing file means enabled, as + * bStats itself treats it. + */ + static boolean bStatsEnabledGlobally(File pluginsDir) { + File config = new File(new File(pluginsDir, "bStats"), "config.yml"); + if (!config.isFile()) return true; + return YamlConfiguration.loadConfiguration(config).getBoolean("enabled", true); + } + + MetricsReporter(SetHomesTwo plugin, int pluginId, BooleanSupplier enabled, Function factory) { + this.plugin = plugin; + this.pluginId = pluginId; + this.enabled = enabled; + this.factory = factory; + } + + /** + * Schedules the start. The bStats switch is read when the task fires, not + * when scheduled. + */ + public void startLater() { + plugin.getServer().getScheduler().runTaskLater(plugin, this::startNow, STARTUP_DELAY_TICKS); + } + + private void startNow() { + if (running != null || pluginId <= 0 || !enabled.getAsBoolean()) return; + try { + running = factory.apply(plugin.getUsageCounters()); + } catch (Throwable t) { + if (ConfigUtil.getDebugLevel().equals(DebugLevel.INFO)) + Bukkit.getLogger().info("Could not start bStats metrics: " + t.getMessage()); + } + } + + public void shutdown() { + if (running == null) return; + try { + running.close(); + } catch (Exception ignored) { + // Nothing sensible to do at shutdown. + } + running = null; + } + + public boolean isRunning() { + return running != null; + } + + /** + * bStats chart id for one command's line chart: `command_` plus the name + * with hyphens as underscores, so `go-home` reports under `command_go_home`. + * Each id has to be registered on the plugin's bStats page. + */ + static String commandChartId(String commandName) { + return "command_" + commandName.toLowerCase(Locale.ROOT).replace('-', '_'); + } + + /** + * bStats chart id for one alias's line chart, `alias_` plus the alias with + * hyphens as underscores. Each id has to be registered on the plugin's + * bStats page. + */ + static String aliasChartId(String alias) { + return "alias_" + alias.toLowerCase(Locale.ROOT).replace('-', '_'); + } + + /** + * Every alias declared in plugin.yml, lower-cased, in declaration order. + */ + static List declaredAliases(SetHomesTwo plugin) { + List aliases = new ArrayList<>(); + for (Map command : plugin.getDescription().getCommands().values()) { + Object declared = command.get("aliases"); + if (declared instanceof String) { + aliases.add(((String) declared).toLowerCase(Locale.ROOT)); + } else if (declared instanceof Iterable) { + for (Object alias : (Iterable) declared) aliases.add(String.valueOf(alias).toLowerCase(Locale.ROOT)); + } + } + return aliases; + } + + /** + * The real bStats wiring. Constructed only on a live server; the relocation + * check inside Metrics throws under an unshaded classpath. + */ + private static final class BStatsHandle implements AutoCloseable { + private final Metrics metrics; + + BStatsHandle(SetHomesTwo plugin, UsageCounters counters) { + metrics = new Metrics(plugin, PLUGIN_ID); + + // bStats keeps history (and time filters) only for line charts, and a + // line chart carries one number, so each usage family gets a bar chart + // for the ranking of the last window plus line charts for trends. + // WindowShare hands every chart the same drained window whatever order + // bStats calls them in, so nothing is read twice. + WindowShare share = new WindowShare(counters); + + for (String command : plugin.getDescription().getCommands().keySet()) { + metrics.addCustomChart(new SingleLineChart(commandChartId(command), + () -> share.count(UsageCounters.Family.COMMAND, command))); + } + metrics.addCustomChart(new SingleLineChart("commands_total", () -> share.total(UsageCounters.Family.COMMAND))); + + for (String alias : declaredAliases(plugin)) { + metrics.addCustomChart(new SingleLineChart(aliasChartId(alias), + () -> share.count(UsageCounters.Family.ALIAS, alias))); + } + + metrics.addCustomChart(new AdvancedBarChart("gui_action_usage", () -> share.bars(UsageCounters.Family.GUI_ACTION))); + metrics.addCustomChart(new SingleLineChart("gui_actions_total", () -> share.total(UsageCounters.Family.GUI_ACTION))); + + metrics.addCustomChart(new AdvancedBarChart("errors", () -> share.bars(UsageCounters.Family.ERROR))); + metrics.addCustomChart(new SingleLineChart("errors_total", () -> share.total(UsageCounters.Family.ERROR))); + + + metrics.addCustomChart(new AdvancedPie("teleport_outcome", () -> counters.snapshotAndReset(UsageCounters.Family.TELEPORT_OUTCOME))); + + metrics.addCustomChart(new SimplePie("max_homes_enabled", () -> String.valueOf(config().getBoolean("maxHomeEnabled", false)))); + metrics.addCustomChart(new SimplePie("max_homes_type", () -> { + if (!config().getBoolean("maxHomeEnabled", false)) return "off"; + String type = config().getString("maxHomesType"); + return type == null ? "groups" : type.toLowerCase(Locale.ROOT); + })); + metrics.addCustomChart(new SimplePie("cancel_on_move", () -> String.valueOf(config().getBoolean("cancelOnMove", true)))); + metrics.addCustomChart(new SimplePie("teleport_safety", () -> String.valueOf(config().getBoolean("teleportSafety", true)))); + metrics.addCustomChart(new SimplePie("teleport_delay", () -> Buckets.delay(config().getInt("delay", 3)))); + metrics.addCustomChart(new SimplePie("open_home_item", () -> defaultOrCustom("openHomeItem", "compass"))); + metrics.addCustomChart(new SimplePie("default_home_item", () -> defaultOrCustom("defaultHomeItem", "white_wool"))); + metrics.addCustomChart(new SimplePie("luckperms_installed", () -> String.valueOf(Bukkit.getPluginManager().getPlugin("LuckPerms") != null))); + metrics.addCustomChart(new SimplePie("homes_per_server", () -> Buckets.homesPerServer(new HomesDao().countAll()))); + metrics.addCustomChart(new SimplePie("homes_per_player", () -> { + HomesDao dao = new HomesDao(); + return Buckets.homesPerPlayer(dao.countAll(), dao.countPlayersWithHomes()); + })); + } + + private static FileConfiguration config() { + return ConfigUtil.getConfig(); + } + + private static String defaultOrCustom(String key, String defaultValue) { + String value = config().getString(key, defaultValue); + return value != null && value.equalsIgnoreCase(defaultValue) ? "default" : "custom"; + } + + @Override + public void close() { + metrics.shutdown(); + } + } +} diff --git a/src/main/java/com/samleighton/sethomestwo/metrics/UsageCounters.java b/src/main/java/com/samleighton/sethomestwo/metrics/UsageCounters.java new file mode 100644 index 0000000..6a5ca9c --- /dev/null +++ b/src/main/java/com/samleighton/sethomestwo/metrics/UsageCounters.java @@ -0,0 +1,67 @@ +package com.samleighton.sethomestwo.metrics; + +import java.util.EnumMap; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.LongAdder; + +/** + * In-memory usage counters, one map per family. Increments are lock-free and + * safe from any thread; a snapshot is what bStats reads once per submission. + */ +public class UsageCounters { + + public enum Family { COMMAND, ALIAS, GUI_ACTION, TELEPORT_OUTCOME, ERROR } + + public static final String OUTCOME_BLACKLISTED = "blacklisted"; + public static final String OUTCOME_ALREADY_TELEPORTING = "already-teleporting"; + public static final String OUTCOME_CANCELLED_MOVED = "cancelled-moved"; + public static final String OUTCOME_UNSAFE = "unsafe"; + public static final String OUTCOME_RELOCATED = "relocated"; + public static final String OUTCOME_COMPLETED = "completed"; + + public static final String GUI_TELEPORT = "teleport"; + public static final String GUI_PAGE_NEXT = "page-next"; + public static final String GUI_PAGE_PREVIOUS = "page-previous"; + + private final Map> families = new EnumMap<>(Family.class); + + public UsageCounters() { + for (Family family : Family.values()) { + families.put(family, new ConcurrentHashMap<>()); + } + } + + public void increment(Family family, String key) { + families.get(family).computeIfAbsent(key, k -> new LongAdder()).increment(); + } + + /** + * Current counts for the family, without clearing them. + */ + public Map snapshot(Family family) { + Map out = new HashMap<>(); + families.get(family).forEach((key, adder) -> { + int value = (int) Math.min(Integer.MAX_VALUE, adder.sum()); + if (value > 0) out.put(key, value); + }); + return out; + } + + /** + * Current counts for the family, then reset so the next window starts at zero. + * This is the shape a bStats AdvancedPie callback returns. + */ + public Map snapshotAndReset(Family family) { + Map out = new HashMap<>(); + ConcurrentHashMap map = families.get(family); + for (String key : map.keySet()) { + LongAdder adder = map.remove(key); + if (adder == null) continue; + int value = (int) Math.min(Integer.MAX_VALUE, adder.sum()); + if (value > 0) out.put(key, value); + } + return out; + } +} diff --git a/src/main/java/com/samleighton/sethomestwo/metrics/WindowShare.java b/src/main/java/com/samleighton/sethomestwo/metrics/WindowShare.java new file mode 100644 index 0000000..fba42d4 --- /dev/null +++ b/src/main/java/com/samleighton/sethomestwo/metrics/WindowShare.java @@ -0,0 +1,64 @@ +package com.samleighton.sethomestwo.metrics; + +import java.util.EnumMap; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.function.LongSupplier; + +/** + * Lets several charts report the same submission window of one counter family + * without draining it more than once. bStats keeps its charts in a hash set, + * so callback order is arbitrary: the first reader of a family in a submission + * drains and parks the window, and every reader within {@link #SAME_SUBMISSION} + * of that gets the parked copy. Submissions are 30 minutes apart and all + * callbacks of one submission run within milliseconds, so the span cannot + * bridge two windows. + */ +final class WindowShare { + + static final long SAME_SUBMISSION = TimeUnit.SECONDS.toNanos(60); + + private final UsageCounters counters; + private final LongSupplier nanoTime; + private final Map> parked = new EnumMap<>(UsageCounters.Family.class); + private final Map parkedAt = new EnumMap<>(UsageCounters.Family.class); + + WindowShare(UsageCounters counters) { + this(counters, System::nanoTime); + } + + WindowShare(UsageCounters counters, LongSupplier nanoTime) { + this.counters = counters; + this.nanoTime = nanoTime; + } + + /** The window in the one-value-per-bar shape AdvancedBarChart wants. */ + Map bars(UsageCounters.Family family) { + Map out = new HashMap<>(); + window(family).forEach((key, count) -> out.put(key, new int[]{count})); + return out; + } + + /** One key's count in the window, 0 when absent. */ + int count(UsageCounters.Family family, String key) { + return window(family).getOrDefault(key, 0); + } + + /** Sum of every key in the window. */ + int total(UsageCounters.Family family) { + int sum = 0; + for (int value : window(family).values()) sum += value; + return sum; + } + + private Map window(UsageCounters.Family family) { + long now = nanoTime.getAsLong(); + Long at = parkedAt.get(family); + if (at == null || now - at > SAME_SUBMISSION) { + parked.put(family, counters.snapshotAndReset(family)); + parkedAt.put(family, now); + } + return parked.get(family); + } +} diff --git a/src/main/java/com/samleighton/sethomestwo/models/Home.java b/src/main/java/com/samleighton/sethomestwo/models/Home.java index 388981d..71f583d 100644 --- a/src/main/java/com/samleighton/sethomestwo/models/Home.java +++ b/src/main/java/com/samleighton/sethomestwo/models/Home.java @@ -6,6 +6,7 @@ import com.samleighton.sethomestwo.enums.UserError; import com.samleighton.sethomestwo.enums.UserInfo; import com.samleighton.sethomestwo.enums.UserSuccess; +import com.samleighton.sethomestwo.metrics.UsageCounters; import com.samleighton.sethomestwo.utils.ChatUtils; import com.samleighton.sethomestwo.utils.ConfigUtil; import com.samleighton.sethomestwo.utils.TeleportSafetyUtil; @@ -43,6 +44,7 @@ public class Home implements Serializable { private float pitch; private float yaw; private boolean canTeleport = true; + private String playerName; public Home(String playerUUID, String material, Location location, String name, String description, String dimension) { setUUIDBelongingTo(playerUUID); @@ -184,9 +186,25 @@ public void setCanTeleport(boolean canTeleport) { this.canTeleport = canTeleport; } + public String getPlayerName() { + return playerName; + } + + public void setPlayerName(String playerName) { + this.playerName = playerName; + } + public void teleport(Player player) { + // The single choke point for every teleport route: the go-home command, + // the admin command, and a click in the homes menu. + if (!player.hasPermission("sh2.teleport")) { + ChatUtils.invalidPermissions(player); + return; + } + // Home is blacklisted guard if(!this.getCanTeleport()) { + countOutcome(UsageCounters.OUTCOME_BLACKLISTED); ChatUtils.sendError(player, ConfigUtil.getConfig().getString("teleportToBlacklistedDimension", UserError.TELEPORT_IS_BLACKLISTED.getValue())); return; } @@ -196,6 +214,7 @@ public void teleport(Player player) { // Guard to check if player is currently teleporting if (isAlreadyTeleporting) { + countOutcome(UsageCounters.OUTCOME_ALREADY_TELEPORTING); ChatUtils.sendError(player, ConfigUtil.getConfig().getString("teleportedWhileTeleporting", UserError.ALREADY_TELEPORTING.getValue())); return; } @@ -213,8 +232,10 @@ public void teleport(Player player) { TeleportSafetyUtil.prefetchChunks(prefetchDestination, plugin); } - // Send player countdown title. - AtomicInteger seconds = new AtomicInteger(ConfigUtil.getConfig().getInt("delay")); + // Send player countdown title. A zero delay runs the existing loop straight + // through to the teleport on its first pass. + AtomicInteger seconds = new AtomicInteger( + player.hasPermission("sh2.bypass-teleport-delay") ? 0 : ConfigUtil.getConfig().getInt("delay")); // Schedule repeating task for every second plugin.getServer().getScheduler().runTaskTimer(plugin, bukkitTask -> { @@ -233,6 +254,7 @@ public void teleport(Player player) { teleportAttemptsDao.delete(player.getUniqueId()); player.resetTitle(); player.removePotionEffect(PotionEffectType.NAUSEA); + countOutcome(UsageCounters.OUTCOME_CANCELLED_MOVED); bukkitTask.cancel(); return; } @@ -259,18 +281,21 @@ public void teleport(Player player) { Location safeDestination = TeleportSafetyUtil.findSafeLocation(destination); TeleportSafetyUtil.releaseChunkTickets(prefetchDestination, plugin); if (safeDestination == null) { + countOutcome(UsageCounters.OUTCOME_UNSAFE); ChatUtils.sendError(player, ConfigUtil.getConfig().getString("unsafeHome", UserError.UNSAFE_HOME.getValue())); player.resetTitle(); player.removePotionEffect(PotionEffectType.NAUSEA); return; } if (safeDestination != destination) { + countOutcome(UsageCounters.OUTCOME_RELOCATED); ChatUtils.sendInfo(player, ConfigUtil.getConfig().getString("movedToSafeSpot", UserInfo.MOVED_TO_SAFE_SPOT.getValue())); } destination = safeDestination; } player.teleport(destination); + countOutcome(UsageCounters.OUTCOME_COMPLETED); player.removePotionEffect(PotionEffectType.NAUSEA); player.resetTitle(); player.playNote(player.getLocation(), Instrument.BELL, Note.sharp(2, Note.Tone.F)); @@ -281,4 +306,8 @@ public void teleport(Player player) { }, 0, 20L); } + + private static void countOutcome(String outcome) { + SetHomesTwo.instance().getUsageCounters().increment(UsageCounters.Family.TELEPORT_OUTCOME, outcome); + } } diff --git a/src/main/java/com/samleighton/sethomestwo/tabcompleters/BlacklistTabCompleter.java b/src/main/java/com/samleighton/sethomestwo/tabcompleters/BlacklistTabCompleter.java new file mode 100644 index 0000000..d13f394 --- /dev/null +++ b/src/main/java/com/samleighton/sethomestwo/tabcompleters/BlacklistTabCompleter.java @@ -0,0 +1,40 @@ +package com.samleighton.sethomestwo.tabcompleters; + +import com.samleighton.sethomestwo.dao.BlacklistDao; +import com.samleighton.sethomestwo.utils.ServerUtil; +import com.samleighton.sethomestwo.utils.TabCompletions; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class BlacklistTabCompleter implements TabCompleter { + + @Nullable + @Override + public List onTabComplete(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { + List completions = new ArrayList<>(); + + // Only the canonical name takes a subcommand argument; the old names go + // straight to world names, matching how the executor reads them. + boolean canonical = "blacklist".equalsIgnoreCase(label); + + if (canonical && args.length == 1) { + completions.addAll(TabCompletions.matching(args[0], Arrays.asList("add", "remove", "list"))); + return completions; + } + + boolean removing = "remove-from-blacklist".equalsIgnoreCase(label) + || (canonical && args.length > 0 && "remove".equalsIgnoreCase(args[0])); + List source = removing ? new BlacklistDao().getAll() : ServerUtil.getValidDimensions(); + + String lastArg = args.length == 0 ? "" : args[args.length - 1]; + completions.addAll(TabCompletions.matching(lastArg, source)); + return completions; + } +} diff --git a/src/main/java/com/samleighton/sethomestwo/tabcompleters/DimensionTabCompleter.java b/src/main/java/com/samleighton/sethomestwo/tabcompleters/DimensionTabCompleter.java index ce9dd79..ff0edfa 100644 --- a/src/main/java/com/samleighton/sethomestwo/tabcompleters/DimensionTabCompleter.java +++ b/src/main/java/com/samleighton/sethomestwo/tabcompleters/DimensionTabCompleter.java @@ -1,10 +1,10 @@ package com.samleighton.sethomestwo.tabcompleters; import com.samleighton.sethomestwo.utils.ServerUtil; +import com.samleighton.sethomestwo.utils.TabCompletions; import org.bukkit.command.Command; import org.bukkit.command.CommandSender; import org.bukkit.command.TabCompleter; -import org.bukkit.util.StringUtil; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -17,7 +17,7 @@ public class DimensionTabCompleter implements TabCompleter { public List onTabComplete(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String s, @NotNull String[] args) { List completions = new ArrayList<>(); for(String arg : args){ - StringUtil.copyPartialMatches(arg, ServerUtil.getValidDimensions(), completions); + completions.addAll(TabCompletions.matching(arg, ServerUtil.getValidDimensions())); } return completions; diff --git a/src/main/java/com/samleighton/sethomestwo/tabcompleters/HomesTabCompleter.java b/src/main/java/com/samleighton/sethomestwo/tabcompleters/HomesTabCompleter.java index 49a7670..d9a7423 100644 --- a/src/main/java/com/samleighton/sethomestwo/tabcompleters/HomesTabCompleter.java +++ b/src/main/java/com/samleighton/sethomestwo/tabcompleters/HomesTabCompleter.java @@ -2,11 +2,11 @@ import com.samleighton.sethomestwo.dao.HomesDao; import com.samleighton.sethomestwo.utils.HomesUtil; +import com.samleighton.sethomestwo.utils.TabCompletions; import org.bukkit.command.Command; import org.bukkit.command.CommandSender; import org.bukkit.command.TabCompleter; import org.bukkit.entity.Player; -import org.bukkit.util.StringUtil; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -25,7 +25,7 @@ public List onTabComplete(@NotNull CommandSender commandSender, @NotNull Player player = (Player) commandSender; List homeNames = HomesUtil.getPlayerHomesNameOnly(new HomesDao(), player.getUniqueId()); - StringUtil.copyPartialMatches(args[0], homeNames, completions); + completions.addAll(TabCompletions.matching(args[0], homeNames)); return completions; } } diff --git a/src/main/java/com/samleighton/sethomestwo/tabcompleters/ImportSourcesTabCompleter.java b/src/main/java/com/samleighton/sethomestwo/tabcompleters/ImportSourcesTabCompleter.java new file mode 100644 index 0000000..991a6c1 --- /dev/null +++ b/src/main/java/com/samleighton/sethomestwo/tabcompleters/ImportSourcesTabCompleter.java @@ -0,0 +1,36 @@ +package com.samleighton.sethomestwo.tabcompleters; + +import com.samleighton.sethomestwo.commands.ImportHomes; +import com.samleighton.sethomestwo.utils.TabCompletions; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +/** + * Completions for /import-homes. Returns an empty list rather than null past the + * arguments it knows, because Bukkit falls back to suggesting online player + * names whenever a completer returns null, which is meaningless here. + */ +public class ImportSourcesTabCompleter implements TabCompleter { + + @Nullable + @Override + public List onTabComplete(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String s, @NotNull String[] args) { + List completions = new ArrayList<>(); + + if (args.length == 1) { + completions.addAll(TabCompletions.matching(args[0], ImportHomes.SOURCES.keySet())); + } + + if (args.length == 2) { + completions.addAll(TabCompletions.matching(args[1], List.of("confirm"))); + } + + return completions; + } +} diff --git a/src/main/java/com/samleighton/sethomestwo/tabcompleters/MaterialsTabCompleter.java b/src/main/java/com/samleighton/sethomestwo/tabcompleters/MaterialsTabCompleter.java index c8db245..b4ad05b 100644 --- a/src/main/java/com/samleighton/sethomestwo/tabcompleters/MaterialsTabCompleter.java +++ b/src/main/java/com/samleighton/sethomestwo/tabcompleters/MaterialsTabCompleter.java @@ -1,15 +1,16 @@ package com.samleighton.sethomestwo.tabcompleters; +import com.samleighton.sethomestwo.utils.TabCompletions; import org.bukkit.Material; import org.bukkit.command.Command; import org.bukkit.command.CommandSender; import org.bukkit.command.TabCompleter; -import org.bukkit.util.StringUtil; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; +import java.util.Locale; public class MaterialsTabCompleter implements TabCompleter { @Nullable @@ -29,10 +30,12 @@ public List onTabComplete(@NotNull CommandSender commandSender, @NotNull Material[] allMaterials = Material.values(); for(Material mat : allMaterials){ if(!mat.isItem()) continue; - validMaterials.add(mat.getKey().toString().toLowerCase()); + // Every non-legacy material is keyed minecraft:, so this equals + // getKey() without touching the deprecated accessor. + validMaterials.add("minecraft:" + mat.name().toLowerCase(Locale.ROOT)); } - StringUtil.copyPartialMatches(args[1], validMaterials, completions); + completions.addAll(TabCompletions.matching(args[1], validMaterials)); return completions; } } diff --git a/src/main/java/com/samleighton/sethomestwo/tabcompleters/PlayerHomesTabCompleter.java b/src/main/java/com/samleighton/sethomestwo/tabcompleters/PlayerHomesTabCompleter.java new file mode 100644 index 0000000..92c8324 --- /dev/null +++ b/src/main/java/com/samleighton/sethomestwo/tabcompleters/PlayerHomesTabCompleter.java @@ -0,0 +1,48 @@ +package com.samleighton.sethomestwo.tabcompleters; + +import com.samleighton.sethomestwo.dao.HomesDao; +import com.samleighton.sethomestwo.models.Home; +import com.samleighton.sethomestwo.utils.ServerUtil; +import com.samleighton.sethomestwo.utils.TabCompletions; +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public class PlayerHomesTabCompleter implements TabCompleter { + + @Nullable + @Override + public List onTabComplete(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String s, @NotNull String[] args) { + List completions = new ArrayList<>(); + + // Only online players are offered, though the commands themselves also + // accept an offline player who has saved homes. + if (args.length == 1) { + List names = new ArrayList<>(); + Bukkit.getOnlinePlayers().forEach(player -> names.add(player.getName())); + completions.addAll(TabCompletions.matching(args[0], names)); + return completions; + } + + if (args.length == 2) { + String uuid = ServerUtil.getPlayerUUID(args[0]); + if (uuid == null) return completions; + + List homeNames = new ArrayList<>(); + for (Home home : new HomesDao(true).getAll(UUID.fromString(uuid))) { + homeNames.add(home.getName()); + } + + completions.addAll(TabCompletions.matching(args[1], homeNames)); + } + + return completions; + } +} diff --git a/src/main/java/com/samleighton/sethomestwo/tabcompleters/RemoveDimensionTabCompleter.java b/src/main/java/com/samleighton/sethomestwo/tabcompleters/RemoveDimensionTabCompleter.java deleted file mode 100644 index 9019905..0000000 --- a/src/main/java/com/samleighton/sethomestwo/tabcompleters/RemoveDimensionTabCompleter.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.samleighton.sethomestwo.tabcompleters; - -import com.samleighton.sethomestwo.dao.BlacklistDao; -import org.bukkit.command.Command; -import org.bukkit.command.CommandSender; -import org.bukkit.command.TabCompleter; -import org.bukkit.util.StringUtil; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.ArrayList; -import java.util.List; - -public class RemoveDimensionTabCompleter implements TabCompleter { - @Nullable - @Override - public List onTabComplete(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String s, @NotNull String[] args) { - List completions = new ArrayList<>(); - - for(String arg : args){ - StringUtil.copyPartialMatches(arg, new BlacklistDao().getAll(), completions); - } - - return completions; - } -} diff --git a/src/main/java/com/samleighton/sethomestwo/utils/DatabaseUtil.java b/src/main/java/com/samleighton/sethomestwo/utils/DatabaseUtil.java index 3fda6ff..06d8552 100644 --- a/src/main/java/com/samleighton/sethomestwo/utils/DatabaseUtil.java +++ b/src/main/java/com/samleighton/sethomestwo/utils/DatabaseUtil.java @@ -1,6 +1,7 @@ package com.samleighton.sethomestwo.utils; import com.samleighton.sethomestwo.enums.DebugLevel; +import com.samleighton.sethomestwo.metrics.Errors; import org.bukkit.Bukkit; import org.jetbrains.annotations.Nullable; @@ -29,6 +30,8 @@ public static boolean initTables(Connection connection){ ");"; boolean createPlayersHomes = execute(connection, String.format(createPlayersHomesSQL, "players_homes")); + boolean playerNameColumn = ensureColumn(connection, "players_homes", "player_name", "TEXT"); + // Create blacklist table String createBlacklistSQL = "create table if not exists %s (\n" + "id integer PRIMARY KEY, \n" + @@ -47,7 +50,24 @@ public static boolean initTables(Connection connection){ ");"; boolean createPlayerTeleportAttempts = execute(connection, String.format(createSQL, "player_teleport_attempts")); - return createPlayerTeleportAttempts && createBlacklist && createPlayersHomes; + return createPlayerTeleportAttempts && createBlacklist && createPlayersHomes && playerNameColumn; + } + + /** + * SQLite has no ADD COLUMN IF NOT EXISTS, so the column list is read first. + */ + private static boolean ensureColumn(Connection connection, String table, String column, String type) { + try (Statement statement = connection.createStatement(); + ResultSet rs = statement.executeQuery("pragma table_info(" + table + ");")) { + while (rs.next()) { + if (column.equalsIgnoreCase(rs.getString("name"))) return true; + } + } catch (SQLException e) { + Bukkit.getLogger().severe("Could not read columns for " + table + ": " + e.getMessage()); + return false; + } + + return execute(connection, String.format("alter table %s add column %s %s;", table, column, type)); } /** @@ -67,6 +87,7 @@ public static boolean execute(Connection connection, String sql, Object... param return true; } catch (SQLException e) { Bukkit.getLogger().severe("Could not execute sql statement."); + Errors.count(Errors.SQL_WRITE); } return false; @@ -87,6 +108,7 @@ public static int executeUpdate(Connection connection, String sql, Object... par return statement.executeUpdate(); } catch (SQLException e) { Bukkit.getLogger().severe("Could not execute sql update statement."); + Errors.count(Errors.SQL_WRITE); } return -1; @@ -109,6 +131,7 @@ public static ResultSet fetch(Connection connection, String sql, Object... param return statement.executeQuery(); } catch (SQLException e) { Bukkit.getLogger().severe("Could not execute sql fetch statement."); + Errors.count(Errors.SQL_READ); } return null; diff --git a/src/main/java/com/samleighton/sethomestwo/utils/HomesUtil.java b/src/main/java/com/samleighton/sethomestwo/utils/HomesUtil.java index 4d7adcc..1d1eca1 100644 --- a/src/main/java/com/samleighton/sethomestwo/utils/HomesUtil.java +++ b/src/main/java/com/samleighton/sethomestwo/utils/HomesUtil.java @@ -9,6 +9,13 @@ public class HomesUtil { + /** + * The name a home takes when created or requested without one. Matches the + * name SetHomesV1Importer gives an imported v1 unnamed home, so an importing + * server's homes line up with what a bare command reaches for. + */ + public static final String DEFAULT_HOME_NAME = "default"; + public static List getPlayerHomesNameOnly(Dao homesDao, UUID playerUUID){ List playerHomes = homesDao.getAll(playerUUID); return Lists.transform(playerHomes, Home::getName); diff --git a/src/main/java/com/samleighton/sethomestwo/utils/PermissionOverrides.java b/src/main/java/com/samleighton/sethomestwo/utils/PermissionOverrides.java new file mode 100644 index 0000000..1add5f2 --- /dev/null +++ b/src/main/java/com/samleighton/sethomestwo/utils/PermissionOverrides.java @@ -0,0 +1,94 @@ +package com.samleighton.sethomestwo.utils; + +import org.bukkit.Bukkit; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.permissions.Permission; +import org.bukkit.permissions.PermissionDefault; +import org.bukkit.plugin.PluginManager; + +import java.util.Locale; + +/** + * Applies the config.yml permissions block over the defaults declared in + * plugin.yml. Only a node's default changes, so an explicit grant or deny in a + * permissions plugin still wins. + */ +public final class PermissionOverrides { + + private PermissionOverrides() { + } + + public static void apply() { + ConfigurationSection section = ConfigUtil.getConfig().getConfigurationSection("permissions"); + if (section == null) return; + + PluginManager pluginManager = Bukkit.getPluginManager(); + + // Deep keys, because Bukkit splits a dotted key such as sh2.import-homes + // into nested sections. The intermediate sections are not nodes. + for (String key : section.getKeys(true)) { + if (section.isConfigurationSection(key)) continue; + + // Bukkit lowercases a node name when looking it up but not when + // storing it as a bundle child, so detachFromBundles only matches + // the canonical spelling. The config value still reads by raw key. + String node = key.toLowerCase(Locale.ROOT); + + Permission permission = pluginManager.getPermission(node); + if (permission == null) { + Bukkit.getLogger().warning(String.format( + "SetHomesTwo: ignoring unknown permission node '%s' in config.yml.", key)); + continue; + } + + String raw = section.getString(key); + PermissionDefault parsed = raw == null ? null : PermissionDefault.getByName(raw); + if (parsed == null) { + Bukkit.getLogger().warning(String.format( + "SetHomesTwo: ignoring permission '%s', value '%s' is not one of true, false, op, not-op.", + key, raw)); + continue; + } + + // Detach before the no-op check below. A node whose default already + // matches still needs freeing from its bundle, or the bundle keeps + // granting the very thing the admin just asked to take away. + if (parsed != PermissionDefault.TRUE) detachFromBundles(pluginManager, node, parsed); + + PermissionDefault previous = permission.getDefault(); + if (previous == parsed) continue; + + permission.setDefault(parsed); + pluginManager.recalculatePermissionDefaults(permission); + + Bukkit.getLogger().info(String.format( + "SetHomesTwo: permission default changed, %s %s to %s", node, previous, parsed)); + + if ("sh2.import-homes".equals(node) && parsed != PermissionDefault.OP) { + Bukkit.getLogger().warning( + "SetHomesTwo: sh2.import-homes is no longer operator only. " + + "/import-homes confirm writes homes for every player on the server."); + } + } + } + + /** + * Drop a node from every bundle that lists it as a child, so the node's own + * default governs again. + * + * Bukkit writes the children of a default-granted parent straight into a + * player's effective permission map, and hasPermission reads that map before + * falling back to the node's default. Lowering the default alone therefore + * denies nothing while a bundle still grants the node. + */ + private static void detachFromBundles(PluginManager pluginManager, String node, PermissionDefault applied) { + for (Permission bundle : pluginManager.getPermissions()) { + if (bundle.getChildren().remove(node) == null) continue; + + pluginManager.recalculatePermissionDefaults(bundle); + Bukkit.getLogger().info(String.format( + "SetHomesTwo: %s is now %s, and was removed from the %s bundle so that applies.", + node, applied, bundle.getName())); + } + } +} diff --git a/src/main/java/com/samleighton/sethomestwo/utils/ServerUtil.java b/src/main/java/com/samleighton/sethomestwo/utils/ServerUtil.java index e362ad9..352bb65 100644 --- a/src/main/java/com/samleighton/sethomestwo/utils/ServerUtil.java +++ b/src/main/java/com/samleighton/sethomestwo/utils/ServerUtil.java @@ -2,7 +2,9 @@ import com.samleighton.sethomestwo.dao.BlacklistDao; import com.samleighton.sethomestwo.dao.Dao; +import com.samleighton.sethomestwo.dao.HomesDao; import org.bukkit.Bukkit; +import org.bukkit.World; import org.bukkit.entity.Player; import java.util.ArrayList; @@ -48,27 +50,46 @@ public static Map getDimensionsMap() { } /** - * Whether homes are barred from a dimension. + * Whether homes are barred from a world. Blacklist rows hold lowercased + * world names, so the world is compared directly rather than through an + * environment mapping, which could only ever address the first three worlds. * - * @param dimension The environment name, as produced by - * world.getEnvironment().toString() - * @return true when the dimension is blacklisted + * @param world The world to check + * @return true when the world is blacklisted */ - public static boolean isDimensionBlacklisted(String dimension) { + public static boolean isWorldBlacklisted(World world) { Dao blacklistDao = new BlacklistDao(); - List blacklistedDimensions = blacklistDao.getAll(); + return isWorldBlacklisted(world, blacklistDao.getAll()); + } - return blacklistedDimensions.contains(getDimensionsMap().get(dimension)); + /** + * Same rule as {@link #isWorldBlacklisted(World)}, against an + * already-fetched blacklist so a caller checking many worlds does not + * query once per check. + * + * @param world The world to check + * @param blacklistedWorlds Lowercased world names, as returned by + * {@code new BlacklistDao().getAll()} + * @return true when the world is blacklisted + */ + public static boolean isWorldBlacklisted(World world, List blacklistedWorlds) { + if (world == null) return false; + + return blacklistedWorlds.contains(world.getName().toLowerCase()); } - public static String getPlayerUUID(String playerName){ - for(Player player : Bukkit.getOnlinePlayers()) { - String name = player.getDisplayName(); - if(name.equals(playerName)) { + /** + * Resolve a player name to a UUID, falling back to the names stored against + * saved homes so offline players can be addressed. The match ignores case, + * which is safe because Minecraft names are unique ignoring case. + */ + public static String getPlayerUUID(String playerName) { + for (Player player : Bukkit.getOnlinePlayers()) { + if (player.getName().equalsIgnoreCase(playerName)) { return player.getUniqueId().toString(); } } - return null; + return new HomesDao().uuidForName(playerName); } } diff --git a/src/main/java/com/samleighton/sethomestwo/utils/TabCompletions.java b/src/main/java/com/samleighton/sethomestwo/utils/TabCompletions.java new file mode 100644 index 0000000..05f2e05 --- /dev/null +++ b/src/main/java/com/samleighton/sethomestwo/utils/TabCompletions.java @@ -0,0 +1,46 @@ +package com.samleighton.sethomestwo.utils; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; + +/** + * Tab completion matching shared by every completer in the plugin. + */ +public final class TabCompletions { + + private TabCompletions() { + } + + /** + * Returns the candidates that contain the fragment, case-insensitively. + * Candidates that start with the fragment come first, then the rest; + * source order is kept within each group and duplicates are dropped. + * + * @param fragment what the player has typed so far; empty matches everything + * @param candidates the names to choose from + * @return the matching candidates, in ranked order, never null + */ + public static List matching(String fragment, Collection candidates) { + String needle = fragment.toLowerCase(Locale.ROOT); + LinkedHashSet prefixHits = new LinkedHashSet<>(); + LinkedHashSet substringHits = new LinkedHashSet<>(); + + for (String candidate : candidates) { + String haystack = candidate.toLowerCase(Locale.ROOT); + if (haystack.startsWith(needle)) { + prefixHits.add(candidate); + } else if (haystack.contains(needle)) { + substringHits.add(candidate); + } + } + + List matches = new ArrayList<>(prefixHits); + for (String hit : substringHits) { + if (!prefixHits.contains(hit)) matches.add(hit); + } + return matches; + } +} diff --git a/src/main/resources/default-config.yml b/src/main/resources/default-config.yml index b45c439..3445b70 100644 --- a/src/main/resources/default-config.yml +++ b/src/main/resources/default-config.yml @@ -3,9 +3,12 @@ # right-clicking to open the homes list. openHomeItem: "compass" -# The default item when create-home command -# is only given a home name, or is only -# provided with the default material name. +# The icon a home gets when the create-home command +# names no icon, or is given "d" or "default" in the +# icon position. This covers a bare /sethome, a +# /sethome with only a name, and a /sethome whose +# second word is description text rather than an item. +# A value naming no item falls back to white wool. defaultHomeItem: "white_wool" # The item to use for moving to the next page @@ -42,20 +45,63 @@ delay: 3 # (seconds) 0 is no delay. teleportSafety: true # Relocate to the nearest safe spot (or cancel) when a home would teleport you into blocks, lava, or a fall. # -- MESSAGES -- -homeCreated: "%s has been created successfully." # You can use %s here as a placeholder for the players home name. +# The first %s is the home name, the second is the icon material. The icon is +# named so that a description starting with a material word, for example +# "/create-home base stone house", makes clear which word was taken as the icon. +# Dropping the second %s is allowed and simply omits the icon. Adding a third +# is not: the command will fail with a formatting error every time. +homeCreated: "%s has been created successfully. Icon: %s" homeDeleted: "%s has been deleted successfully." # You can use %s here as a placeholder for the players home name. -dimensionAddedToBlacklist: "%s has been added to the blacklist." # You can use %s here as a placeholder for the dimension names. +dimensionAddedToBlacklist: "%s has been added to the blacklist." # %s is the world name. +dimensionRemovedFromBlacklist: "%s has been removed from the blacklist." # %s is the world name. + +# Shown when a world name given to /blacklist is not a world on this server. +# Takes TWO placeholders, in this order: what was typed, then the list of +# this server's world names. A bare percent sign anywhere in an override +# throws a formatting error, so write %% if you need a literal one. +invalidWorld: "%s is not a valid world. This server's worlds are: %s" + +# The compass item handed out by /give-homes-item. +# homeItemName takes one placeholder, the player's name. +homeItemName: "Home's of %s" +homeItemLore: "Right click this item to open your home's list." + +# Replies to the admin commands that act on another player's home. +# Both take TWO placeholders, in this order: the player name first, then +# the home name. Swapping them swaps the two words in the message, and +# adding a third %s makes the command fail with a formatting error. +playerHomeDeleted: "%s's home '%s' has been deleted." +playerHomeMoved: "%s's home '%s' has been moved to your location." + +# Shown on join to anyone holding sh2.import-homes while Set Homes v1 homes are +# sitting in plugins/SetHomes/homes.yml and none have been imported here yet. +# The single %s is how many are waiting. It stops on its own once any home +# exists here, so there is nothing to switch off after the import. +v1ImportPending: "Set Homes v1 has %s home(s) waiting to be imported. Run /import-homes sethomes for a preview, then add confirm to bring them across." # -- ERROR MESSAGES -- + +# Vestigial. No code path reaches this message any more: create-home treats a +# word that names no item as description text, and the management menu can only +# be handed an item you are already holding. Kept so an existing config.yml that +# overrides it is not suddenly rejected. invalidHomeItem: "The material you entered is not valid, please try a different one." falseHomeItem: "This home item does not belong to you." teleportedWhileTeleporting: "You cannot teleport while already teleporting." -movedWhileTeleporting: "Your teleport has been cancelled because you have moved." +movedWhileTeleporting: "Your teleport has been canceled because you have moved." noHomes: "You have not created any homes yet. Use /create-home to make your first one." teleportToBlacklistedDimension: "You cannot teleport to this home because the dimension it is in has been blacklisted." maxHomesReached: "You have reached the maximum number of homes allowed." dimensionBlacklisted: "You cannot set a home in this dimension because it has been blacklisted." -unsafeHome: "Teleport cancelled: this home is not safe to stand in and no safe spot was found nearby." +# Shown by /go-home, /move-home and the three admin commands that take a home +# name. The single %s is the home name that was looked for; a bare /go-home +# looks for 'default'. Keep exactly one %s. A bare percent sign anywhere in an +# override throws a formatting error every time the message is sent. +homeDoesNotExist: "The home '%s' does not exist." + +# Shown when a player name matches nobody online and nobody with saved homes. +playerNotFound: "No player by that name is online or has any saved homes." +unsafeHome: "Teleport canceled: this home is not safe to stand in and no safe spot was found nearby." movedToSafeSpot: "Your home was not safe to stand in, so you were moved to the nearest safe spot." # -- DEBUGGING -- @@ -71,7 +117,7 @@ renamePromptTitle: "New home name" # Hint shown on each home in the homes list, below the description. # Only shown to players who can actually manage the home (sh2.manage-homes) -# and never on another player's homes. Supports '&' colour codes. +# and never on another player's homes. Supports '&' color codes. # Set to "" to hide it. manageHomeHint: "&7Right click to edit home" @@ -79,7 +125,7 @@ manageHomeHint: "&7Right click to edit home" maxHomeNameLength: 32 # Buttons in the management menu. -# Button names support '&' colour codes (e.g. &c for red). +# Button names support '&' color codes (e.g. &c for red). renameButtonItem: "name_tag" renameButtonName: "Rename" moveHomeButtonItem: "ender_pearl" @@ -120,3 +166,25 @@ checkForUpdates: true # repeat it. A release nobody has been told about is always announced straight # away, whatever this is set to. Use 0 to announce each release exactly once. updateReminderDays: 7 + +# -- PERMISSIONS -- +# Override the built-in default for any sh2.* permission node, without +# installing a permissions plugin. Accepted values: true | false | op | not-op +# +# This only changes the default. An explicit grant or deny in LuckPerms still +# wins. Unknown node names are ignored with a warning in the server log, and +# every applied override is logged at startup. +# +# There is no wildcard form. List each node you want to change. The two +# bundles, sh2.player and sh2.admin, are nodes in their own right, so you can +# name either of them here to move a whole set at once. +# +# Values are read by Bukkit, which also accepts case variants such as OP and +# True, plus its own spellings such as notop. Stick to the four above. +# +# Take care with sh2.import-homes: /import-homes confirm writes homes +# for every player on the server, and the command has no second permission check. +# +# permissions: +# sh2.import-homes: op +# sh2.manage-homes: false diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index edbb186..1d131b2 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -20,21 +20,24 @@ commands: permission: sh2.delete-home aliases: - delhome - add-to-blacklist: - description: Add dimensions to be blacklisted from setting homes. - permission: sh2.add-to-blacklist - remove-from-blacklist: - description: Remove dimensions from blacklist table. - permission: sh2.remove-from-blacklist - get-blacklisted-dimensions: - description: Retrieves a list of the blacklisted dimensions. - permission: sh2.get-blacklisted-dimensions + blacklist: + description: Manage the worlds in which homes are disallowed. + # Semicolon-separated permissions are OR'd by Bukkit's own Command.testPermission, + # so the command is reachable by a holder of any one of the three nodes; add and + # remove are still gated on their own node inside the executor. + permission: sh2.add-to-blacklist;sh2.remove-from-blacklist;sh2.get-blacklisted-dimensions + aliases: + - add-to-blacklist + - remove-from-blacklist + - get-blacklisted-dimensions get-player-homes: description: Retrieves a list of a given player's homes. permission: sh2.get-player-homes set-max-homes: description: Sets the max number of homes all players, or individual groups, can have. permission: sh2.set-max-homes + aliases: + - setmax go-home: description: Allows a player to use commands to teleport to homes. permission: sh2.go-home @@ -49,46 +52,117 @@ commands: import-homes: description: Import homes from another homes plugin (dry-run unless confirmed). permission: sh2.import-homes + move-home: + description: Move one of your homes to where you are standing. + permission: sh2.move-home + aliases: + - uhome + go-player-home: + description: Teleport to another player's home. + permission: sh2.go-player-home + aliases: + - home-of + delete-player-home: + description: Delete another player's home. + permission: sh2.delete-player-home + aliases: + - delhome-of + move-player-home: + description: Move another player's home to your location. + permission: sh2.move-player-home + aliases: + - uhome-of permissions: sh2.create-home: description: Allow player to create homes. - default: true + default: false sh2.go-home: description: Allow player to teleport to homes by command. - default: true + default: false sh2.list-homes: description: Allow player to list and view their homes. - default: true + default: false sh2.delete-home: description: Allow player to delete their own homes. - default: true + default: false sh2.teleport: description: Allow player to teleport to created homes. - default: true + default: false sh2.give-homes-item: description: Allow player to get the homes viewing item. - default: true + default: false sh2.manage-homes: description: Allow player to rename, move, re-icon, and delete their homes from the GUI. - default: true + default: false + sh2.move-home: + description: Allow a player to move their own homes by command. + default: false sh2.add-to-blacklist: description: Add dimensions to the blacklist. - default: op + default: false sh2.remove-from-blacklist: description: Remove dimensions from the blacklist. - default: op + default: false sh2.get-blacklisted-dimensions: description: List blacklisted dimensions. - default: op + default: false sh2.get-player-homes: description: View another player's homes. - default: op + default: false sh2.set-max-homes: description: Set the max number of homes. - default: op + default: false sh2.import-homes: description: Import homes from other plugins. - default: op + default: false sh2.update-notify: description: Be told on join when a newer SetHomesTwo release is available. + default: false + sh2.go-player-home: + description: Teleport to another player's home. + default: false + sh2.delete-player-home: + description: Delete another player's home. + default: false + sh2.move-player-home: + description: Move another player's home. + default: false + sh2.bypass-max-homes: + description: Create homes beyond the configured maximum. + default: false + sh2.bypass-blacklist: + description: Create, move and teleport to homes in blacklisted worlds. + default: false + sh2.bypass-teleport-delay: + description: Teleport instantly, without the countdown or the cancel on move. + default: false + sh2.player: + description: Everything an ordinary player needs. + default: true + children: + sh2.create-home: true + sh2.go-home: true + sh2.list-homes: true + sh2.delete-home: true + sh2.teleport: true + sh2.give-homes-item: true + sh2.manage-homes: true + sh2.move-home: true + sh2.admin: + description: Everything a server administrator needs, including the player nodes. default: op + children: + sh2.player: true + sh2.add-to-blacklist: true + sh2.remove-from-blacklist: true + sh2.get-blacklisted-dimensions: true + sh2.get-player-homes: true + sh2.set-max-homes: true + sh2.import-homes: true + sh2.update-notify: true + sh2.go-player-home: true + sh2.delete-player-home: true + sh2.move-player-home: true + sh2.bypass-max-homes: true + sh2.bypass-blacklist: true + sh2.bypass-teleport-delay: true diff --git a/src/test/java/com/samleighton/sethomestwo/PendingV1ImportNoticeTest.java b/src/test/java/com/samleighton/sethomestwo/PendingV1ImportNoticeTest.java new file mode 100644 index 0000000..9093739 --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/PendingV1ImportNoticeTest.java @@ -0,0 +1,228 @@ +package com.samleighton.sethomestwo; + +import com.samleighton.sethomestwo.support.FailOnUnimplemented; +import com.samleighton.sethomestwo.support.HomeFixtures; +import com.samleighton.sethomestwo.support.PluginBoot; +import com.samleighton.sethomestwo.support.TestPlayer; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Boots the plugin by hand rather than through ServerTestBase, because v1's + * homes.yml has to be on disk before our onEnable looks for it. + */ +@ExtendWith(FailOnUnimplemented.class) +class PendingV1ImportNoticeTest { + + private ServerMock server; + + @BeforeEach + void startServer() { + server = MockBukkit.mock(); + + // Same overworld, nether, end order ServerTestBase uses: ServerUtil maps + // environments onto worlds by list position. + server.addSimpleWorld("world").setEnvironment(World.Environment.NORMAL); + server.addSimpleWorld("world_nether").setEnvironment(World.Environment.NETHER); + server.addSimpleWorld("world_the_end").setEnvironment(World.Environment.THE_END); + } + + @AfterEach + void stopServer() { + MockBukkit.unmock(); + } + + @Test + void saysSoAtStartupWhenV1HomesAreWaitingAndNothingIsImported() { + writeV1Homes(2, 0); + + String block = warningText(captureLog(PluginBoot::load)); + + assertTrue(block.contains("plugins/SetHomes/homes.yml"), "should name the file it found"); + assertTrue(block.contains("2"), "should say how many homes are waiting"); + assertTrue(block.contains("/import-homes sethomes"), "should name the command to run"); + } + + @Test + void v1UnnamedHomesAreCountedToo() { + writeV1Homes(1, 1); + + String block = warningText(captureLog(PluginBoot::load)); + + assertTrue(block.contains("2 home"), + "v1 keeps a player's unnamed home under unknownHomes, and it imports like any other"); + } + + @Test + void aJoiningAdminIsToldTheImportIsWaiting() { + writeV1Homes(2, 0); + SetHomesTwo plugin = PluginBoot.load(); + + TestPlayer admin = join(plugin, "Admin", true); + + assertTrue(messagesTo(admin).contains("/import-homes sethomes"), + "plenty of admins never read the console, so the notice has to reach them in chat"); + } + + @Test + void aJoiningPlayerWithoutTheImportPermissionIsNotTold() { + writeV1Homes(2, 0); + SetHomesTwo plugin = PluginBoot.load(); + + TestPlayer player = join(plugin, "Regular", false); + + assertFalse(messagesTo(player).contains("import-homes"), + "only someone who can run the import has any use for the notice"); + } + + @Test + void theNoticeRepeatsOnEveryJoin() { + writeV1Homes(2, 0); + SetHomesTwo plugin = PluginBoot.load(); + TestPlayer admin = join(plugin, "Admin", true); + messagesTo(admin); + admin.disconnect(); + + // MockBukkit drops the attachment on disconnect; a real op or permissions + // group survives a reconnect, so grant it again before the second join. + admin.addAttachment(plugin, "sh2.import-homes", true); + server.addPlayer(admin); + + assertTrue(messagesTo(admin).contains("/import-homes sethomes"), + "nothing is remembered per player, so an admin who missed it sees it next time"); + } + + @Test + void theNoticeStopsOnceAHomeExistsHere() { + writeV1Homes(2, 0); + SetHomesTwo plugin = PluginBoot.load(); + TestPlayer first = join(plugin, "First", true); + HomeFixtures.persist(first, "base"); + + TestPlayer second = join(plugin, "Second", true); + + assertFalse(messagesTo(second).contains("import-homes"), + "an empty database is the whole condition, so one home ends the notice"); + } + + @Test + void nothingIsSaidWhenThereIsNoV1File() { + String block = warningText(captureLog(PluginBoot::load)); + + assertFalse(block.contains("import-homes"), "most servers have never had v1 installed"); + } + + @Test + void nothingIsSaidWhenTheV1FileHoldsNoHomes() { + writeV1Homes(0, 0); + + String block = warningText(captureLog(PluginBoot::load)); + + assertFalse(block.contains("import-homes"), "an empty homes.yml has nothing to offer"); + } + + /** + * Joins a player, granting sh2.import-homes outright rather than opping, so + * the tests pin the permission the notice is actually gated on. + */ + private TestPlayer join(SetHomesTwo plugin, String name, boolean mayImport) { + TestPlayer player = new TestPlayer(server, name); + if (mayImport) player.addAttachment(plugin, "sh2.import-homes", true); + server.addPlayer(player); + return player; + } + + private String messagesTo(TestPlayer player) { + StringBuilder all = new StringBuilder(); + String message; + while ((message = player.nextMessage()) != null) all.append(message).append("\n"); + return all.toString(); + } + + /** + * Writes a v1 homes.yml in the layout SetHomesV1Importer reads: named homes + * under allNamedHomes.uuid.name, unnamed ones under unknownHomes.uuid. + */ + private void writeV1Homes(int named, int unnamed) { + YamlConfiguration source = new YamlConfiguration(); + for (int i = 0; i < named; i++) { + writeOne(source, "allNamedHomes." + UUID.randomUUID() + ".home" + i); + } + for (int i = 0; i < unnamed; i++) { + writeOne(source, "unknownHomes." + UUID.randomUUID()); + } + + File v1Dir = new File(server.getPluginsFolder(), "SetHomes"); + if (!v1Dir.isDirectory() && !v1Dir.mkdirs()) + throw new IllegalStateException("could not create " + v1Dir); + try { + source.save(new File(v1Dir, "homes.yml")); + } catch (IOException e) { + throw new IllegalStateException("could not write the v1 fixture", e); + } + } + + private void writeOne(YamlConfiguration source, String path) { + source.set(path + ".world", "world"); + source.set(path + ".x", 1.0); + source.set(path + ".y", 64.0); + source.set(path + ".z", 1.0); + source.set(path + ".pitch", 0.0); + source.set(path + ".yaw", 0.0); + } + + private List captureLog(Runnable action) { + List captured = new ArrayList<>(); + Handler handler = new Handler() { + @Override + public void publish(LogRecord record) { + captured.add(record); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }; + + Logger logger = Bukkit.getLogger(); + logger.addHandler(handler); + try { + action.run(); + } finally { + logger.removeHandler(handler); + } + return captured; + } + + private String warningText(List records) { + return records.stream() + .filter(record -> record.getLevel() == Level.WARNING) + .map(LogRecord::getMessage) + .collect(Collectors.joining("\n")); + } +} diff --git a/src/test/java/com/samleighton/sethomestwo/SetHomesV1ClashTest.java b/src/test/java/com/samleighton/sethomestwo/SetHomesV1ClashTest.java new file mode 100644 index 0000000..93d6d9d --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/SetHomesV1ClashTest.java @@ -0,0 +1,150 @@ +package com.samleighton.sethomestwo; + +import com.samleighton.sethomestwo.support.FailOnUnimplemented; +import org.bukkit.Bukkit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Boots the plugin by hand rather than through ServerTestBase, because the v1 + * plugin has to be registered before our onEnable runs. + */ +@ExtendWith(FailOnUnimplemented.class) +class SetHomesV1ClashTest { + + private ServerMock server; + + @BeforeEach + void startServer() { + server = MockBukkit.mock(); + } + + @AfterEach + void stopServer() { + MockBukkit.unmock(); + } + + @Test + void refusesToEnableWhenSetHomesV1IsLoaded() { + MockBukkit.createMockPlugin("SetHomes"); + + SetHomesTwo plugin = MockBukkit.load(SetHomesTwo.class); + + assertFalse(plugin.isEnabled(), + "both jars installed splits /sethome and /homes between the two plugins, so we must not enable"); + } + + @Test + void theRefusalSaysWhichFileToMoveAndWhatToRunAfterwards() { + MockBukkit.createMockPlugin("SetHomes"); + + String block = severeText(captureLog(() -> MockBukkit.load(SetHomesTwo.class))); + + assertTrue(block.contains("plugins/"), "should name where the old jar is"); + assertTrue(block.contains("/import-homes sethomes"), "should name the command to run once it is gone"); + assertTrue(block.contains("keep"), "should say to keep the old jar so a rollback stays possible"); + assertFalse(block.contains("delete"), "deleting the old jar throws away the rollback"); + } + + @Test + void aRefusedBootCreatesNothingInOurDataFolder() { + MockBukkit.createMockPlugin("SetHomes"); + + SetHomesTwo plugin = MockBukkit.load(SetHomesTwo.class); + + assertFalse(new File(plugin.getDataFolder(), "config.yml").exists(), + "the guard must run before initConfig"); + assertFalse(new File(plugin.getDataFolder(), "database").exists(), + "the guard must run before createDirectories"); + } + + @Test + void enablesNormallyWhenSetHomesV1IsAbsent() { + assertTrue(loadPlugin().isEnabled()); + } + + @Test + void aPluginWhoseNameOnlyStartsWithSetHomesIsNotV1() { + MockBukkit.createMockPlugin("SetHomesThree"); + + assertTrue(loadPlugin().isEnabled()); + } + + @Test + void theNameMatchIsCaseSensitive() { + MockBukkit.createMockPlugin("sethomes"); + + assertTrue(loadPlugin().isEnabled()); + } + + /** + * Loads for the cases that expect a normal boot, with the same two switches + * ServerTestBase applies so a drained scheduler cannot reach the network. + */ + private SetHomesTwo loadPlugin() { + SetHomesTwo plugin = MockBukkit.load(SetHomesTwo.class); + plugin.getConfig().set("checkForUpdates", false); + + File bStatsDir = new File(plugin.getDataFolder().getParentFile(), "bStats"); + if (!bStatsDir.isDirectory() && !bStatsDir.mkdirs()) + throw new IllegalStateException("could not create " + bStatsDir); + try { + Files.writeString(new File(bStatsDir, "config.yml").toPath(), "enabled: false\n"); + } catch (IOException e) { + throw new IllegalStateException("could not write the bStats opt-out", e); + } + return plugin; + } + + private List captureLog(Runnable action) { + List captured = new ArrayList<>(); + Handler handler = new Handler() { + @Override + public void publish(LogRecord record) { + captured.add(record); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }; + + Logger logger = Bukkit.getLogger(); + logger.addHandler(handler); + try { + action.run(); + } finally { + logger.removeHandler(handler); + } + return captured; + } + + private String severeText(List records) { + return records.stream() + .filter(record -> record.getLevel() == Level.SEVERE) + .map(LogRecord::getMessage) + .collect(Collectors.joining("\n")); + } +} diff --git a/src/test/java/com/samleighton/sethomestwo/commands/BlacklistTest.java b/src/test/java/com/samleighton/sethomestwo/commands/BlacklistTest.java new file mode 100644 index 0000000..844ab83 --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/commands/BlacklistTest.java @@ -0,0 +1,200 @@ +package com.samleighton.sethomestwo.commands; + +import com.samleighton.sethomestwo.dao.BlacklistDao; +import com.samleighton.sethomestwo.support.HomeFixtures; +import com.samleighton.sethomestwo.support.ServerTestBase; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BlacklistTest extends ServerTestBase { + + @Test + void addStoresTheWorld() { + PlayerMock player = addPlayer(); + player.addAttachment(plugin, "sh2.add-to-blacklist", true); + + assertTrue(server.execute("blacklist", player, "add", "world_nether").hasSucceeded()); + + assertTrue(new BlacklistDao().getAll().contains("world_nether")); + } + + @Test + void removeDropsTheWorld() { + HomeFixtures.blacklist("world_nether"); + PlayerMock player = addPlayer(); + player.addAttachment(plugin, "sh2.remove-from-blacklist", true); + + assertTrue(server.execute("blacklist", player, "remove", "world_nether").hasSucceeded()); + + assertFalse(new BlacklistDao().getAll().contains("world_nether")); + } + + @Test + void listPrintsTheEntries() { + HomeFixtures.blacklist("world_nether"); + PlayerMock player = addPlayer(); + player.addAttachment(plugin, "sh2.get-blacklisted-dimensions", true); + + assertTrue(server.execute("blacklist", player, "list").hasSucceeded()); + + assertTrue(player.nextMessage().contains("world_nether")); + } + + @Test + void addIsRefusedWithoutItsOwnNode() { + PlayerMock player = addPlayer(); + player.addAttachment(plugin, "sh2.get-blacklisted-dimensions", true); + player.addAttachment(plugin, "sh2.add-to-blacklist", false); + + assertTrue(server.execute("blacklist", player, "add", "world_nether").hasSucceeded()); + + assertTrue(player.nextMessage().contains("permission")); + assertFalse(new BlacklistDao().getAll().contains("world_nether")); + } + + @Test + void removeIsRefusedWithoutItsOwnNode() { + HomeFixtures.blacklist("world_nether"); + PlayerMock player = addPlayer(); + player.addAttachment(plugin, "sh2.get-blacklisted-dimensions", true); + player.addAttachment(plugin, "sh2.remove-from-blacklist", false); + + assertTrue(server.execute("blacklist", player, "remove", "world_nether").hasSucceeded()); + + assertTrue(player.nextMessage().contains("permission")); + assertTrue(new BlacklistDao().getAll().contains("world_nether")); + } + + @Test + void listIsRefusedWithoutItsOwnNode() { + HomeFixtures.blacklist("world_nether"); + PlayerMock player = addPlayer(); + player.addAttachment(plugin, "sh2.add-to-blacklist", true); + player.addAttachment(plugin, "sh2.remove-from-blacklist", true); + player.addAttachment(plugin, "sh2.get-blacklisted-dimensions", false); + + assertTrue(server.execute("blacklist", player, "list").hasSucceeded()); + + assertTrue(player.nextMessage().contains("permission")); + } + + @Test + void theOldCommandNameStillWorks() { + PlayerMock player = addPlayer(); + player.addAttachment(plugin, "sh2.add-to-blacklist", true); + + assertTrue(server.execute("add-to-blacklist", player, "add", "world_nether").hasSucceeded()); + + assertTrue(new BlacklistDao().getAll().contains("world_nether")); + } + + @Test + void anUnknownWorldIsRejected() { + PlayerMock player = addPlayer(); + player.addAttachment(plugin, "sh2.add-to-blacklist", true); + + assertTrue(server.execute("blacklist", player, "add", "not_a_world").hasSucceeded()); + + String message = player.nextMessage(); + assertTrue(message.contains("not_a_world"), message); + // The old wording advertised nether, overworld and end, none of which + // the validator accepts. It must name the server's real worlds instead. + assertTrue(message.contains("world_nether"), message); + assertTrue(new BlacklistDao().getAll().isEmpty()); + } + + @Test + void theAddSuccessMessageIsOverridableInConfig() { + PlayerMock player = addPlayer(); + player.addAttachment(plugin, "sh2.add-to-blacklist", true); + plugin.getConfig().set("dimensionAddedToBlacklist", "Blocked %s."); + + assertTrue(server.execute("blacklist", player, "add", "world_nether").hasSucceeded()); + + assertTrue(player.nextMessage().contains("Blocked world_nether.")); + } + + @Test + void aFailedAddIsReportedInsteadOfClaimingSuccess() { + PlayerMock player = addPlayer(); + player.addAttachment(plugin, "sh2.add-to-blacklist", true); + HomeFixtures.breakBlacklistWrites(); + + assertTrue(server.execute("blacklist", player, "add", "world_nether").hasSucceeded()); + + String message = player.nextMessage(); + assertTrue(message.contains("issue adding dimension"), message); + assertNull(player.nextMessage(), "a failed write must not also send the success message"); + assertFalse(new BlacklistDao().getAll().contains("world_nether")); + } + + @Test + void aFailedRemoveIsReportedInsteadOfClaimingSuccess() { + HomeFixtures.blacklist("world_nether"); + PlayerMock player = addPlayer(); + player.addAttachment(plugin, "sh2.remove-from-blacklist", true); + HomeFixtures.breakBlacklistWrites(); + + assertTrue(server.execute("blacklist", player, "remove", "world_nether").hasSucceeded()); + + String message = player.nextMessage(); + assertTrue(message.contains("issue removing dimension"), message); + assertNull(player.nextMessage(), "a failed write must not also send the success message"); + assertTrue(new BlacklistDao().getAll().contains("world_nether")); + } + + // The old command names arrive at onCommand with no subcommand token at + // all (e.g. "/add-to-blacklist world_nether"), unlike the new "blacklist" + // name, which always expects one. Bukkit hands onCommand the exact label + // the player typed only when the command is dispatched through the real + // command line, so these use server.dispatchCommand rather than + // server.execute, which always reports the canonical command name as the + // label regardless of which alias was used to look it up. + + @Test + void bareAddToBlacklistAliasWithNoSubcommandStillAdds() { + PlayerMock player = addPlayer(); + player.addAttachment(plugin, "sh2.add-to-blacklist", true); + + server.dispatchCommand(player, "add-to-blacklist world_nether"); + + assertTrue(new BlacklistDao().getAll().contains("world_nether")); + } + + @Test + void bareRemoveFromBlacklistAliasWithNoSubcommandStillRemoves() { + HomeFixtures.blacklist("world_nether"); + PlayerMock player = addPlayer(); + player.addAttachment(plugin, "sh2.remove-from-blacklist", true); + + server.dispatchCommand(player, "remove-from-blacklist world_nether"); + + assertFalse(new BlacklistDao().getAll().contains("world_nether")); + } + + @Test + void bareGetBlacklistedDimensionsAliasListsEntries() { + HomeFixtures.blacklist("world_nether"); + PlayerMock player = addPlayer(); + player.addAttachment(plugin, "sh2.get-blacklisted-dimensions", true); + + server.dispatchCommand(player, "get-blacklisted-dimensions"); + + assertTrue(player.nextMessage().contains("world_nether")); + } + + @Test + void explicitSubcommandViaOldAliasIsNotTreatedAsAWorldName() { + PlayerMock player = addPlayer(); + player.addAttachment(plugin, "sh2.add-to-blacklist", true); + + server.dispatchCommand(player, "add-to-blacklist add world_nether"); + + assertTrue(new BlacklistDao().getAll().contains("world_nether")); + assertFalse(new BlacklistDao().getAll().contains("add")); + } +} diff --git a/src/test/java/com/samleighton/sethomestwo/commands/CreateHomeTest.java b/src/test/java/com/samleighton/sethomestwo/commands/CreateHomeTest.java index f43a3c2..3f1a491 100644 --- a/src/test/java/com/samleighton/sethomestwo/commands/CreateHomeTest.java +++ b/src/test/java/com/samleighton/sethomestwo/commands/CreateHomeTest.java @@ -1,6 +1,7 @@ package com.samleighton.sethomestwo.commands; import com.samleighton.sethomestwo.dao.HomesDao; +import com.samleighton.sethomestwo.models.Home; import com.samleighton.sethomestwo.support.HomeFixtures; import com.samleighton.sethomestwo.support.ServerTestBase; import org.bukkit.Bukkit; @@ -17,6 +18,7 @@ import java.util.logging.Logger; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; class CreateHomeTest extends ServerTestBase { @@ -26,7 +28,7 @@ void aHomeIsCreatedAtThePlayersLocation() { PlayerMock player = addPlayer(); player.teleport(new Location(overworld, 12, 65, -8)); - server.execute("create-home", player, "base").assertSucceeded(); + assertTrue(server.execute("create-home", player, "base").hasSucceeded()); var homes = new HomesDao().getAll(player.getUniqueId()); assertEquals(1, homes.size()); @@ -35,13 +37,49 @@ void aHomeIsCreatedAtThePlayersLocation() { } @Test - void missingNameIsRejected() { + void aBareCommandCreatesTheDefaultHome() { PlayerMock player = addPlayer(); + player.teleport(new Location(overworld, 7, 65, 7)); - server.execute("create-home", player).assertSucceeded(); + assertTrue(server.execute("create-home", player).hasSucceeded()); - assertTrue(player.nextMessage().contains("Incorrect number of arguments")); - assertTrue(new HomesDao().getAll(player.getUniqueId()).isEmpty()); + List homes = new HomesDao().getAll(player.getUniqueId()); + assertEquals(1, homes.size()); + assertEquals("default", homes.get(0).getName()); + assertEquals(7.0, homes.get(0).getX()); + assertEquals(Material.WHITE_WOOL.name(), homes.get(0).getMaterial()); + assertNull(homes.get(0).getDescription()); + } + + @Test + void theNameDefaultBehavesLikeTheBareCommand() { + PlayerMock player = addPlayer(); + + assertTrue(server.execute("create-home", player, "default").hasSucceeded()); + player.nextMessage(); + assertTrue(server.execute("create-home", player).hasSucceeded()); + + assertTrue(player.nextMessage().contains("You already have a home called")); + List homes = new HomesDao().getAll(player.getUniqueId()); + assertEquals(1, homes.size()); + assertEquals("default", homes.get(0).getName()); + } + + /** + * Argument 2 'default' is the icon sentinel, not the default home name. The + * two meanings live in different argument positions and must not be merged. + */ + @Test + void theIconSentinelNeverBecomesTheHomeName() { + PlayerMock player = addPlayer(); + + assertTrue(server.execute("create-home", player, "base", "default").hasSucceeded()); + + List homes = new HomesDao().getAll(player.getUniqueId()); + assertEquals(1, homes.size()); + assertEquals("base", homes.get(0).getName()); + assertEquals(Material.WHITE_WOOL.name(), homes.get(0).getMaterial()); + assertNull(homes.get(0).getDescription()); } @Test @@ -49,27 +87,65 @@ void aDuplicateNameIsRejected() { PlayerMock player = addPlayer(); HomeFixtures.persist(player, "base"); - server.execute("create-home", player, "BASE").assertSucceeded(); + assertTrue(server.execute("create-home", player, "BASE").hasSucceeded()); assertTrue(player.nextMessage().contains("You already have a home called")); assertEquals(1, new HomesDao().getAll(player.getUniqueId()).size()); } @Test - void anInvalidMaterialIsRejected() { + void aNonMaterialSecondArgumentBecomesTheDescription() { PlayerMock player = addPlayer(); - server.execute("create-home", player, "base", "not_a_material").assertSucceeded(); + assertTrue(server.execute("create-home", player, "base", "my", "main", "base").hasSucceeded()); - assertTrue(player.nextMessage().contains("not valid")); - assertTrue(new HomesDao().getAll(player.getUniqueId()).isEmpty()); + var homes = new HomesDao().getAll(player.getUniqueId()); + assertEquals(1, homes.size()); + assertEquals("my main base", homes.get(0).getDescription()); + assertEquals(Material.WHITE_WOOL.name(), homes.get(0).getMaterial()); + } + + @Test + void aMaterialSecondArgumentStillSetsTheIcon() { + PlayerMock player = addPlayer(); + + assertTrue(server.execute("create-home", player, "base", "diamond_block", "my", "base").hasSucceeded()); + + var homes = new HomesDao().getAll(player.getUniqueId()); + assertEquals(Material.DIAMOND_BLOCK.name(), homes.get(0).getMaterial()); + assertEquals("my base", homes.get(0).getDescription()); + } + + @Test + void theChosenIconIsNamedInTheSuccessMessage() { + PlayerMock player = addPlayer(); + + assertTrue(server.execute("create-home", player, "base", "diamond_block").hasSucceeded()); + + assertTrue(player.nextMessage().contains("DIAMOND_BLOCK")); + } + + /** + * Pins the trade-off the forgiving parsing introduces, so it stays a + * documented behaviour rather than a surprise. Looks like a duplicate of + * aMaterialSecondArgumentStillSetsTheIcon; it is not, keep both. + */ + @Test + void aDescriptionBeginningWithAMaterialWordLosesThatWordToTheIcon() { + PlayerMock player = addPlayer(); + + assertTrue(server.execute("create-home", player, "base", "stone", "house").hasSucceeded()); + + Home home = new HomesDao().getAll(player.getUniqueId()).get(0); + assertEquals(Material.STONE.name(), home.getMaterial()); + assertEquals("house", home.getDescription()); } @Test void aSuppliedMaterialIsStored() { PlayerMock player = addPlayer(); - server.execute("create-home", player, "base", "diamond").assertSucceeded(); + assertTrue(server.execute("create-home", player, "base", "diamond").hasSucceeded()); assertEquals(Material.DIAMOND.name(), new HomesDao().getAll(player.getUniqueId()).get(0).getMaterial()); } @@ -80,7 +156,7 @@ void aBlacklistedDimensionIsRejected() { player.teleport(new Location(overworld, 0, 64, 0)); HomeFixtures.blacklist(overworld.getName()); - server.execute("create-home", player, "base").assertSucceeded(); + assertTrue(server.execute("create-home", player, "base").hasSucceeded()); assertTrue(player.nextMessage().contains("You cannot set a home in this dimension")); assertTrue(new HomesDao().getAll(player.getUniqueId()).isEmpty()); @@ -95,7 +171,7 @@ void theSingularMaxHomesLimitIsEnforced() { HomeFixtures.persist(player, "base"); - server.execute("create-home", player, "camp").assertSucceeded(); + assertTrue(server.execute("create-home", player, "camp").hasSucceeded()); assertTrue(player.nextMessage().contains("maximum number of homes")); assertEquals(1, new HomesDao().getAll(player.getUniqueId()).size()); @@ -111,7 +187,7 @@ void groupLimitsAreSkippedWhenLuckPermsIsAbsent() { // The count alone would pass even if the groups branch were never reached. List logged = captureLog(() -> - server.execute("create-home", player, "camp").assertSucceeded()); + assertTrue(server.execute("create-home", player, "camp").hasSucceeded())); assertEquals(2, new HomesDao().getAll(player.getUniqueId()).size()); assertTrue(loggedWarning(logged, @@ -155,4 +231,111 @@ private boolean loggedWarning(List records, String message) { return records.stream().anyMatch( record -> record.getLevel() == Level.WARNING && message.equals(record.getMessage())); } + + @Test + void aValidButNonItemMaterialWordIsDescriptionText() { + PlayerMock player = addPlayer(); + + // water names a real Material but not an item. Storing it as the icon + // would make HomesGui throw on new ItemStack and the menu stop opening. + assertTrue(server.execute("create-home", player, "base", "water", "front").hasSucceeded()); + + Home home = new HomesDao().getAll(player.getUniqueId()).get(0); + assertEquals(Material.WHITE_WOOL.name(), home.getMaterial()); + assertEquals("water front", home.getDescription()); + } + + @Test + void theHomesMenuStillOpensAfterAHomeNamedAfterANonItem() { + PlayerMock player = addPlayer(); + assertTrue(server.execute("create-home", player, "base", "water", "front").hasSucceeded()); + + assertTrue(server.execute("homes", player).hasSucceeded()); + } + + /** + * Reachable by typing a double space, which Bukkit turns into an empty + * argument rather than dropping it. + */ + @Test + void anEmptySecondArgumentDoesNotLeakIntoTheDescription() { + PlayerMock player = addPlayer(); + + server.dispatchCommand(player, "create-home base hi"); + + Home home = new HomesDao().getAll(player.getUniqueId()).get(0); + assertEquals(Material.WHITE_WOOL.name(), home.getMaterial()); + assertEquals("hi", home.getDescription()); + } + + @Test + void theDefaultIconSentinelIsNotDescriptionText() { + PlayerMock player = addPlayer(); + + assertTrue(server.execute("create-home", player, "base", "d").hasSucceeded()); + assertTrue(server.execute("create-home", player, "camp", "default", "my", "spot").hasSucceeded()); + + List homes = new HomesDao().getAll(player.getUniqueId()); + Home base = homes.stream().filter(h -> h.getName().equals("base")).findFirst().orElseThrow(); + Home camp = homes.stream().filter(h -> h.getName().equals("camp")).findFirst().orElseThrow(); + + assertEquals(Material.WHITE_WOOL.name(), base.getMaterial()); + assertNull(base.getDescription()); + assertEquals(Material.WHITE_WOOL.name(), camp.getMaterial()); + assertEquals("my spot", camp.getDescription()); + } + + @Test + void aDefaultIconThatNamesNoItemFallsBackToWhiteWool() { + PlayerMock player = addPlayer(); + plugin.getConfig().set("defaultHomeItem", "water"); + + assertTrue(server.execute("create-home", player, "base").hasSucceeded()); + + assertEquals(Material.WHITE_WOOL.name(), new HomesDao().getAll(player.getUniqueId()).get(0).getMaterial()); + } + + @Test + void theConfiguredDefaultIconIsUsedWhenNoneIsGiven() { + PlayerMock player = addPlayer(); + plugin.getConfig().set("defaultHomeItem", "chest"); + + assertTrue(server.execute("create-home", player, "base").hasSucceeded()); + + assertEquals(Material.CHEST.name(), new HomesDao().getAll(player.getUniqueId()).get(0).getMaterial()); + } + + @Test + void anEmptyHomeNameIsRejected() { + PlayerMock player = addPlayer(); + + // A double space makes Bukkit hand over an empty first argument. Saved + // as-is it produces a home no command can name, so no command can + // delete it either. + server.dispatchCommand(player, "create-home base"); + + assertTrue(player.nextMessage().contains("must not be blank")); + assertTrue(new HomesDao().getAll(player.getUniqueId()).isEmpty()); + } + + @Test + void aHomeNameOverTheConfiguredLimitIsRejected() { + PlayerMock player = addPlayer(); + plugin.getConfig().set("maxHomeNameLength", 8); + + assertTrue(server.execute("create-home", player, "waaaaaaaaaaaytoolong").hasSucceeded()); + + assertTrue(player.nextMessage().contains("too long")); + assertTrue(new HomesDao().getAll(player.getUniqueId()).isEmpty()); + } + + @Test + void aNameWithinTheConfiguredLimitIsAccepted() { + PlayerMock player = addPlayer(); + plugin.getConfig().set("maxHomeNameLength", 8); + + assertTrue(server.execute("create-home", player, "base").hasSucceeded()); + + assertEquals(1, new HomesDao().getAll(player.getUniqueId()).size()); + } } diff --git a/src/test/java/com/samleighton/sethomestwo/commands/DeleteHomeTest.java b/src/test/java/com/samleighton/sethomestwo/commands/DeleteHomeTest.java index 171c560..1674add 100644 --- a/src/test/java/com/samleighton/sethomestwo/commands/DeleteHomeTest.java +++ b/src/test/java/com/samleighton/sethomestwo/commands/DeleteHomeTest.java @@ -16,7 +16,7 @@ class DeleteHomeTest extends ServerTestBase { @Test void consoleIsTurnedAway() { - server.executeConsole("delete-home", "base").assertSucceeded(); + assertTrue(server.executeConsole("delete-home", "base").hasSucceeded()); assertTrue(server.getConsoleSender().nextMessage().contains("Only players")); } @@ -25,7 +25,7 @@ void wrongArgumentCountReportsUsage() { PlayerMock player = addPlayer(); HomeFixtures.persist(player, "base"); - server.execute("delete-home", player).assertSucceeded(); + assertTrue(server.execute("delete-home", player).hasSucceeded()); assertTrue(player.nextMessage().contains("Incorrect number of arguments")); assertEquals(1, new HomesDao().getAll(player.getUniqueId()).size()); @@ -36,7 +36,7 @@ void anUnknownHomeIsReportedAndNothingIsDeleted() { PlayerMock player = addPlayer(); HomeFixtures.persist(player, "base"); - server.execute("delete-home", player, "nowhere").assertSucceeded(); + assertTrue(server.execute("delete-home", player, "nowhere").hasSucceeded()); assertTrue(player.nextMessage().contains("You do not have a home by the name")); assertEquals(1, new HomesDao().getAll(player.getUniqueId()).size()); @@ -48,7 +48,7 @@ void aHomeIsDeleted() { HomeFixtures.persist(player, "base"); HomeFixtures.persist(player, "camp"); - server.execute("delete-home", player, "base").assertSucceeded(); + assertTrue(server.execute("delete-home", player, "base").hasSucceeded()); List remaining = new HomesDao().getAll(player.getUniqueId()); assertEquals(1, remaining.size()); @@ -61,7 +61,7 @@ void withTwoHomesSharingANameOnlyOneIsDeleted() { HomeFixtures.persist(player, "base"); HomeFixtures.persist(player, "base"); - server.execute("delete-home", player, "base").assertSucceeded(); + assertTrue(server.execute("delete-home", player, "base").hasSucceeded()); // The 1.2.0 behaviour change: previously this removed every matching row. assertEquals(1, new HomesDao().getAll(player.getUniqueId()).size()); diff --git a/src/test/java/com/samleighton/sethomestwo/commands/GetPlayerHomesTest.java b/src/test/java/com/samleighton/sethomestwo/commands/GetPlayerHomesTest.java index 76e0558..2eef45a 100644 --- a/src/test/java/com/samleighton/sethomestwo/commands/GetPlayerHomesTest.java +++ b/src/test/java/com/samleighton/sethomestwo/commands/GetPlayerHomesTest.java @@ -17,7 +17,7 @@ class GetPlayerHomesTest extends ServerTestBase { void aNonOpIsRefused() { PlayerMock admin = addPlayer(); - server.execute("get-player-homes", admin, "someone").assertSucceeded(); + assertTrue(server.execute("get-player-homes", admin, "someone").hasSucceeded()); // Bukkit's dispatcher rejects non-op senders before onCommand runs, so this // is its denial message, not the plugin's. The substring matches both. @@ -29,9 +29,9 @@ void anOfflineOrUnknownPlayerIsReported() { PlayerMock admin = addPlayer(); admin.setOp(true); - server.execute("get-player-homes", admin, "nobody").assertSucceeded(); + assertTrue(server.execute("get-player-homes", admin, "nobody").hasSucceeded()); - assertTrue(admin.nextMessage().contains("not online")); + assertTrue(admin.nextMessage().contains("No player by that name")); } @Test @@ -39,7 +39,7 @@ void wrongArgumentCountIsReported() { PlayerMock admin = addPlayer(); admin.setOp(true); - server.execute("get-player-homes", admin).assertSucceeded(); + assertTrue(server.execute("get-player-homes", admin).hasSucceeded()); assertTrue(admin.nextMessage().contains("Incorrect number of arguments")); } @@ -51,7 +51,7 @@ void anAdminSeesAnotherPlayersHomes() { admin.setOp(true); HomeFixtures.persist(target, "base"); - server.execute("get-player-homes", admin, "target").assertSucceeded(); + assertTrue(server.execute("get-player-homes", admin, "target").hasSucceeded()); GuiSession session = plugin.getGuiSessionMap().get(admin.getUniqueId()); assertNotNull(session); diff --git a/src/test/java/com/samleighton/sethomestwo/commands/GoHomeTest.java b/src/test/java/com/samleighton/sethomestwo/commands/GoHomeTest.java index eebbfe3..6a17789 100644 --- a/src/test/java/com/samleighton/sethomestwo/commands/GoHomeTest.java +++ b/src/test/java/com/samleighton/sethomestwo/commands/GoHomeTest.java @@ -1,6 +1,7 @@ package com.samleighton.sethomestwo.commands; import com.samleighton.sethomestwo.dao.TeleportAttemptsDao; +import com.samleighton.sethomestwo.metrics.UsageCounters; import com.samleighton.sethomestwo.models.Home; import com.samleighton.sethomestwo.support.HomeFixtures; import com.samleighton.sethomestwo.support.ServerTestBase; @@ -9,7 +10,10 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.util.Map; + import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -24,27 +28,79 @@ void disableTeleportSafety() { plugin.getConfig().set("teleportSafety", false); } + private Map outcomes() { + return plugin.getUsageCounters().snapshot(UsageCounters.Family.TELEPORT_OUTCOME); + } + @Test void consoleIsTurnedAway() { - server.executeConsole("go-home", "base").assertSucceeded(); + assertTrue(server.executeConsole("go-home", "base").hasSucceeded()); assertTrue(server.getConsoleSender().nextMessage().contains("Only players")); } @Test - void wrongArgumentCountIsRejected() { + void tooManyArgumentsAreRejected() { TestPlayer player = addTestPlayer("traveller"); - server.execute("go-home", player).assertSucceeded(); + assertTrue(server.execute("go-home", player, "base", "camp").hasSucceeded()); assertTrue(player.nextMessage().contains("Incorrect number of arguments")); } + @Test + void aBareCommandTeleportsToTheDefaultHome() { + TestPlayer player = addTestPlayer("traveller"); + player.teleport(new Location(overworld, 0, 64, 0)); + HomeFixtures.persist(HomeFixtures.home(player, "default", new Location(overworld, 44, 70, 44))); + plugin.getConfig().set("delay", 0); + + assertTrue(server.execute("go-home", player).hasSucceeded()); + server.getScheduler().performTicks(100L); + + assertEquals(44, player.getLocation().getBlockX()); + assertEquals(44, player.getLocation().getBlockZ()); + } + + @Test + void aBareCommandWithNoDefaultHomeIsReported() { + TestPlayer player = addTestPlayer("traveller"); + HomeFixtures.persist(player, "base"); + + assertTrue(server.execute("go-home", player).hasSucceeded()); + + String message = player.nextMessage(); + assertTrue(message.contains("default"), message); + assertNull(new TeleportAttemptsDao().get(player)); + } + + @Test + void anUnknownHomeIsNamedInTheError() { + TestPlayer player = addTestPlayer("traveller"); + + assertTrue(server.execute("go-home", player, "nope").hasSucceeded()); + + String message = player.nextMessage(); + assertTrue(message.contains("nope"), message); + assertFalse(message.contains("%s"), message); + } + + @Test + void theUnknownHomeMessageIsOverridableInConfig() { + TestPlayer player = addTestPlayer("traveller"); + plugin.getConfig().set("homeDoesNotExist", "No home called %s here."); + + assertTrue(server.execute("go-home", player, "nope").hasSucceeded()); + + String message = player.nextMessage(); + assertTrue(message.contains("No home called nope here."), message); + } + @Test void withoutPermissionTheCommandIsRefused() { TestPlayer player = addTestPlayer("traveller"); player.addAttachment(plugin, "sh2.go-home", false); - server.execute("go-home", player, "base").assertSucceeded(); + assertTrue(server.execute("go-home", player, "base").hasSucceeded()); assertTrue(player.nextMessage().contains("do not have permission")); } @@ -54,7 +110,7 @@ void anUnknownHomeIsReported() { TestPlayer player = addTestPlayer("traveller"); HomeFixtures.persist(player, "base"); - server.execute("go-home", player, "nowhere").assertSucceeded(); + assertTrue(server.execute("go-home", player, "nowhere").hasSucceeded()); assertTrue(player.nextMessage().contains("does not exist")); } @@ -67,7 +123,7 @@ void aBlacklistedHomeRefusesToTeleport() { HomeFixtures.blacklist(overworld.getName()); Location before = player.getLocation(); - server.execute("go-home", player, "base").assertSucceeded(); + assertTrue(server.execute("go-home", player, "base").hasSucceeded()); server.getScheduler().performTicks(100L); assertTrue(player.nextMessage().contains("cannot teleport to this home")); @@ -82,11 +138,11 @@ void aSecondTeleportWhileOneIsRunningIsRefused() { HomeFixtures.persist(HomeFixtures.home(player, "base", new Location(overworld, 100, 70, 100))); plugin.getConfig().set("delay", 3); - server.execute("go-home", player, "base").assertSucceeded(); + assertTrue(server.execute("go-home", player, "base").hasSucceeded()); server.getScheduler().performOneTick(); player.nextMessage(); - server.execute("go-home", player, "base").assertSucceeded(); + assertTrue(server.execute("go-home", player, "base").hasSucceeded()); assertTrue(player.nextMessage().contains("cannot teleport while already teleporting")); } @@ -98,7 +154,7 @@ void theCountdownRecordsAnAttemptAndTitlesThePlayer() { HomeFixtures.persist(HomeFixtures.home(player, "base", new Location(overworld, 100, 70, 100))); plugin.getConfig().set("delay", 3); - server.execute("go-home", player, "base").assertSucceeded(); + assertTrue(server.execute("go-home", player, "base").hasSucceeded()); server.getScheduler().performOneTick(); assertNotNull(new TeleportAttemptsDao().get(player)); @@ -112,11 +168,69 @@ void thePlayerArrivesOnceTheCountdownCompletes() { HomeFixtures.persist(HomeFixtures.home(player, "base", new Location(overworld, 100, 70, 100))); plugin.getConfig().set("delay", 0); - server.execute("go-home", player, "base").assertSucceeded(); + assertTrue(server.execute("go-home", player, "base").hasSucceeded()); server.getScheduler().performTicks(100L); assertEquals(100, player.getLocation().getBlockX()); assertEquals(100, player.getLocation().getBlockZ()); assertNull(new TeleportAttemptsDao().get(player)); } + + @Test + void withoutTheTeleportNodeNoRouteToAHomeWorks() { + TestPlayer player = addPlayer(); + HomeFixtures.persist(HomeFixtures.home(player, "base", new Location(overworld, 60, 70, 60))); + player.addAttachment(plugin, "sh2.teleport", false); + player.teleport(new Location(overworld, 0, 70, 0)); + + assertTrue(server.execute("go-home", player, "base").hasSucceeded()); + server.getScheduler().performTicks(100L); + + assertTrue(player.nextMessage().contains("permission")); + assertEquals(0.0, player.getLocation().getX()); + } + + @Test + void aCompletedCommandTeleportCountsTheOutcome() { + TestPlayer player = addTestPlayer("traveller"); + player.teleport(new Location(overworld, 0, 64, 0)); + HomeFixtures.persist(HomeFixtures.home(player, "base", new Location(overworld, 44, 70, 44))); + plugin.getConfig().set("delay", 0); + + assertTrue(server.execute("go-home", player, "base").hasSucceeded()); + server.getScheduler().performTicks(100L); + + assertEquals(1, outcomes().get(UsageCounters.OUTCOME_COMPLETED)); + assertEquals(1, outcomes().size()); + } + + @Test + void movingDuringTheCountdownCountsACancelledTeleport() { + TestPlayer player = addTestPlayer("traveller"); + player.teleport(new Location(overworld, 0, 64, 0)); + HomeFixtures.persist(HomeFixtures.home(player, "base", new Location(overworld, 100, 70, 100))); + plugin.getConfig().set("delay", 3); + + assertTrue(server.execute("go-home", player, "base").hasSucceeded()); + server.getScheduler().performOneTick(); + player.teleport(new Location(overworld, 5, 64, 5)); + server.getScheduler().performTicks(100L); + + assertEquals(1, outcomes().get(UsageCounters.OUTCOME_CANCELLED_MOVED)); + assertFalse(outcomes().containsKey(UsageCounters.OUTCOME_COMPLETED)); + } + + @Test + void aSecondTeleportWhileOneIsRunningCountsAlreadyTeleporting() { + TestPlayer player = addTestPlayer("traveller"); + player.teleport(new Location(overworld, 0, 64, 0)); + HomeFixtures.persist(HomeFixtures.home(player, "base", new Location(overworld, 100, 70, 100))); + plugin.getConfig().set("delay", 3); + + assertTrue(server.execute("go-home", player, "base").hasSucceeded()); + server.getScheduler().performOneTick(); + assertTrue(server.execute("go-home", player, "base").hasSucceeded()); + + assertEquals(1, outcomes().get(UsageCounters.OUTCOME_ALREADY_TELEPORTING)); + } } diff --git a/src/test/java/com/samleighton/sethomestwo/commands/ImportHomesTest.java b/src/test/java/com/samleighton/sethomestwo/commands/ImportHomesTest.java new file mode 100644 index 0000000..741df9c --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/commands/ImportHomesTest.java @@ -0,0 +1,105 @@ +package com.samleighton.sethomestwo.commands; + +import com.samleighton.sethomestwo.dao.BlacklistDao; +import com.samleighton.sethomestwo.support.ServerTestBase; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +import java.io.File; +import java.io.IOException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ImportHomesTest extends ServerTestBase { + + private File setHomesDir() { + File dir = new File(plugin.getDataFolder().getParentFile(), "SetHomes"); + dir.mkdirs(); + return dir; + } + + private void writeEmptyHomesFile() throws IOException { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.save(new File(setHomesDir(), "homes.yml")); + } + + private void writeBlacklist(String... worlds) throws IOException { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("blacklisted_worlds", List.of(worlds)); + yaml.save(new File(setHomesDir(), "world_blacklist.yml")); + } + + private PlayerMock authorizedPlayer() { + PlayerMock player = addPlayer(); + player.addAttachment(plugin, "sh2.import-homes", true); + return player; + } + + @Test + void blacklistActivityAddsASecondReplyLine() throws IOException { + writeEmptyHomesFile(); + writeBlacklist("world_nether"); + PlayerMock player = authorizedPlayer(); + + assertTrue(server.execute("import-homes", player, "sethomes").hasSucceeded()); + + player.nextMessage(); // homes summary line + assertTrue(player.nextMessage().contains("blacklist")); + } + + @Test + void noBlacklistActivityMeansNoSecondLine() throws IOException { + writeEmptyHomesFile(); + PlayerMock player = authorizedPlayer(); + + assertTrue(server.execute("import-homes", player, "sethomes").hasSucceeded()); + + // 0 homes and 0 blacklist activity: only the summary line is sent. The + // dry-run hint is gated on (imported > 0 || blacklistImported > 0), so + // it does not fire here either. + String summary = player.nextMessage(); + assertFalse(summary.contains("blacklist")); + assertNull(player.nextMessage()); + } + + @Test + void dryRunHintIsNotShownWhenTheOnlyBlacklistActivityIsAlreadyPresent() throws IOException { + writeEmptyHomesFile(); + new BlacklistDao().save("world_nether"); + writeBlacklist("world_nether"); + PlayerMock player = authorizedPlayer(); + + assertTrue(server.execute("import-homes", player, "sethomes").hasSucceeded()); + + boolean sawHint = false; + String message; + while ((message = player.nextMessage()) != null) { + if (message.contains("Dry run only")) sawHint = true; + } + assertFalse(sawHint); + } + + @Test + void configNotesArePrintedAsConfigLines() throws IOException { + writeEmptyHomesFile(); + YamlConfiguration v1Config = new YamlConfiguration(); + v1Config.set("tp-cooldown", 30); + v1Config.save(new File(setHomesDir(), "config.yml")); + PlayerMock player = authorizedPlayer(); + + assertTrue(server.execute("import-homes", player, "sethomes").hasSucceeded()); + + boolean sawConfigLine = false; + String message; + while ((message = player.nextMessage()) != null) { + if (message.startsWith("Config: ") && message.contains("tp-cooldown")) { + sawConfigLine = true; + } + } + assertTrue(sawConfigLine); + } +} diff --git a/src/test/java/com/samleighton/sethomestwo/commands/ListHomesTest.java b/src/test/java/com/samleighton/sethomestwo/commands/ListHomesTest.java new file mode 100644 index 0000000..2083053 --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/commands/ListHomesTest.java @@ -0,0 +1,25 @@ +package com.samleighton.sethomestwo.commands; + +import com.samleighton.sethomestwo.support.ServerTestBase; +import org.bukkit.ChatColor; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ListHomesTest extends ServerTestBase { + + @Test + void aPlayerWithNoHomesGetsTheSameNoticeAsTheMenu() { + PlayerMock player = addPlayer(); + + assertTrue(server.execute("list-homes", player).hasSucceeded()); + + String message = player.nextMessage(); + assertTrue(message.contains("You have not created any homes yet. Use /create-home to make your first one.")); + assertFalse(message.contains(ChatColor.RED.toString()), "having no homes is not an error"); + assertNull(player.nextMessage(), "one line is enough"); + } +} diff --git a/src/test/java/com/samleighton/sethomestwo/commands/MoveHomeTest.java b/src/test/java/com/samleighton/sethomestwo/commands/MoveHomeTest.java new file mode 100644 index 0000000..6be327d --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/commands/MoveHomeTest.java @@ -0,0 +1,110 @@ +package com.samleighton.sethomestwo.commands; + +import com.samleighton.sethomestwo.dao.HomesDao; +import com.samleighton.sethomestwo.models.Home; +import com.samleighton.sethomestwo.support.HomeFixtures; +import com.samleighton.sethomestwo.support.ServerTestBase; +import org.bukkit.Location; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MoveHomeTest extends ServerTestBase { + + @Test + void theHomeMovesToThePlayersLocation() { + PlayerMock player = addPlayer(); + HomeFixtures.persist(player, "base"); + player.teleport(new Location(overworld, 100, 70, -40)); + + assertTrue(server.execute("move-home", player, "base").hasSucceeded()); + + Home moved = new HomesDao().getAll(player.getUniqueId()).get(0); + assertEquals(100.0, moved.getX()); + assertEquals(-40.0, moved.getZ()); + } + + @Test + void anUnknownHomeIsReported() { + PlayerMock player = addPlayer(); + + assertTrue(server.execute("move-home", player, "nope").hasSucceeded()); + + String message = player.nextMessage(); + assertTrue(message.contains("nope"), message); + assertFalse(message.contains("%s"), message); + } + + @Test + void withoutPermissionTheCommandIsRefused() { + PlayerMock player = addPlayer(); + HomeFixtures.persist(player, "base"); + player.addAttachment(plugin, "sh2.move-home", false); + Location before = player.getLocation(); + player.teleport(new Location(overworld, 100, 70, -40)); + + assertTrue(server.execute("move-home", player, "base").hasSucceeded()); + + assertTrue(player.nextMessage().contains("permission")); + assertEquals(before.getX(), new HomesDao().getAll(player.getUniqueId()).get(0).getX()); + } + + @Test + void movingIntoABlacklistedWorldIsRefused() { + PlayerMock player = addPlayer(); + HomeFixtures.persist(player, "base"); + HomeFixtures.blacklist("world_nether"); + player.teleport(new Location(nether, 10, 70, 10)); + + assertTrue(server.execute("move-home", player, "base").hasSucceeded()); + + assertTrue(player.nextMessage().contains("blacklisted")); + } + + @Test + void theAliasWorks() { + PlayerMock player = addPlayer(); + HomeFixtures.persist(player, "base"); + player.teleport(new Location(overworld, 5, 70, 5)); + + assertTrue(server.execute("uhome", player, "base").hasSucceeded()); + + assertEquals(5.0, new HomesDao().getAll(player.getUniqueId()).get(0).getX()); + } + + @Test + void movingAcrossWorldsRewritesTheWorldAndDimension() { + PlayerMock player = addPlayer(); + HomeFixtures.persist(player, "base"); + player.teleport(new Location(nether, 8, 70, 8)); + + assertTrue(server.execute("move-home", player, "base").hasSucceeded()); + + Home moved = new HomesDao().getAll(player.getUniqueId()).get(0); + assertEquals(nether.getUID().toString(), moved.getWorld()); + assertEquals("NETHER", moved.getDimension()); + } + + @Test + void theWrongNumberOfArgumentsShowsTheUsage() { + PlayerMock player = addPlayer(); + + assertTrue(server.execute("move-home", player).hasSucceeded()); + + assertTrue(player.nextMessage().contains("Incorrect number of arguments")); + assertTrue(player.nextMessage().contains("Usage: /move-home ")); + } + + @Test + void theUsageNamesTheAliasThatWasTyped() { + PlayerMock player = addPlayer(); + + server.dispatchCommand(player, "uhome"); + + player.nextMessage(); + assertTrue(player.nextMessage().contains("Usage: /uhome ")); + } +} diff --git a/src/test/java/com/samleighton/sethomestwo/commands/OpenHomesGuiTest.java b/src/test/java/com/samleighton/sethomestwo/commands/OpenHomesGuiTest.java index 0e1564d..4acef3a 100644 --- a/src/test/java/com/samleighton/sethomestwo/commands/OpenHomesGuiTest.java +++ b/src/test/java/com/samleighton/sethomestwo/commands/OpenHomesGuiTest.java @@ -3,18 +3,20 @@ import com.samleighton.sethomestwo.gui.GuiSession; import com.samleighton.sethomestwo.support.HomeFixtures; import com.samleighton.sethomestwo.support.ServerTestBase; +import org.bukkit.ChatColor; import org.junit.jupiter.api.Test; import org.mockbukkit.mockbukkit.entity.PlayerMock; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class OpenHomesGuiTest extends ServerTestBase { @Test void consoleIsTurnedAway() { - server.executeConsole("homes").assertSucceeded(); + assertTrue(server.executeConsole("homes").hasSucceeded()); // The command reports back rather than doing anything. assertTrue(server.getConsoleSender().nextMessage().contains("Only players")); } @@ -23,9 +25,11 @@ void consoleIsTurnedAway() { void aPlayerWithNoHomesIsTold() { PlayerMock player = addPlayer(); - server.execute("homes", player).assertSucceeded(); + assertTrue(server.execute("homes", player).hasSucceeded()); - assertTrue(player.nextMessage().contains("You have not created any homes yet.")); + String message = player.nextMessage(); + assertTrue(message.contains("You have not created any homes yet.")); + assertFalse(message.contains(ChatColor.RED.toString()), "having no homes is not an error"); } @Test @@ -33,7 +37,7 @@ void aPlayerWithHomesGetsASession() { PlayerMock player = addPlayer(); HomeFixtures.persist(player, "base"); - server.execute("homes", player).assertSucceeded(); + assertTrue(server.execute("homes", player).hasSucceeded()); GuiSession session = plugin.getGuiSessionMap().get(player.getUniqueId()); assertNotNull(session); @@ -49,7 +53,7 @@ void theSessionIsReusedAcrossInvocations() { // first call to populate it, so assertSame actually proves reuse. plugin.getGuiSessionMap().clear(); - server.execute("homes", player).assertSucceeded(); + assertTrue(server.execute("homes", player).hasSucceeded()); GuiSession first = plugin.getGuiSessionMap().get(player.getUniqueId()); assertNotNull(first); // activeScreen starts null and is only set by openHomeList, so this @@ -57,7 +61,7 @@ void theSessionIsReusedAcrossInvocations() { // some session object happens to exist. assertNotNull(first.getActiveScreen()); - server.execute("homes", player).assertSucceeded(); + assertTrue(server.execute("homes", player).hasSucceeded()); GuiSession second = plugin.getGuiSessionMap().get(player.getUniqueId()); assertSame(first, second); diff --git a/src/test/java/com/samleighton/sethomestwo/commands/PlayerHomeAdminCommandsTest.java b/src/test/java/com/samleighton/sethomestwo/commands/PlayerHomeAdminCommandsTest.java new file mode 100644 index 0000000..84ce506 --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/commands/PlayerHomeAdminCommandsTest.java @@ -0,0 +1,372 @@ +package com.samleighton.sethomestwo.commands; + +import com.samleighton.sethomestwo.dao.HomesDao; +import com.samleighton.sethomestwo.metrics.UsageCounters; +import com.samleighton.sethomestwo.support.HomeFixtures; +import com.samleighton.sethomestwo.support.ServerTestBase; +import com.samleighton.sethomestwo.support.TestPlayer; +import com.samleighton.sethomestwo.tabcompleters.PlayerHomesTabCompleter; +import org.bukkit.Location; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.ClickType; +import com.samleighton.sethomestwo.gui.HomesGui; +import com.samleighton.sethomestwo.gui.GuiSession; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PlayerHomeAdminCommandsTest extends ServerTestBase { + + @Test + void deletingAnOfflinePlayersHomeWorks() { + PlayerMock target = addPlayer("Steve"); + HomeFixtures.persist(target, "base"); + UUID targetId = target.getUniqueId(); + target.disconnect(); + + PlayerMock admin = addPlayer("Admin"); + admin.addAttachment(plugin, "sh2.delete-player-home", true); + + assertTrue(server.execute("delete-player-home", admin, "Steve", "base").hasSucceeded()); + + assertTrue(new HomesDao(true).getAll(targetId).isEmpty()); + } + + @Test + void movingAnOfflinePlayersHomeUsesTheAdminLocation() { + PlayerMock target = addPlayer("Steve"); + HomeFixtures.persist(target, "base"); + UUID targetId = target.getUniqueId(); + target.disconnect(); + + PlayerMock admin = addPlayer("Admin"); + admin.addAttachment(plugin, "sh2.move-player-home", true); + admin.teleport(new Location(overworld, 250, 70, 250)); + + assertTrue(server.execute("move-player-home", admin, "Steve", "base").hasSucceeded()); + + assertEquals(250.0, new HomesDao(true).getAll(targetId).get(0).getX()); + } + + @Test + void teleportingToAnOfflinePlayersHomeIsAccepted() { + PlayerMock target = addPlayer("Steve"); + HomeFixtures.persist(HomeFixtures.home(target, "base", new Location(overworld, 30, 70, 30))); + target.disconnect(); + + PlayerMock admin = addPlayer("Admin"); + admin.addAttachment(plugin, "sh2.go-player-home", true); + admin.addAttachment(plugin, "sh2.teleport", true); + admin.teleport(new Location(overworld, 0, 64, 0)); + plugin.getConfig().set("delay", 0); + // TeleportSafetyUtil.prefetchChunks reaches an unimplemented MockBukkit call. + plugin.getConfig().set("teleportSafety", false); + + assertTrue(server.execute("go-player-home", admin, "Steve", "base").hasSucceeded()); + server.getScheduler().performTicks(100L); + + assertEquals(30.0, admin.getLocation().getX()); + } + + @Test + void anUnknownPlayerIsReported() { + PlayerMock admin = addPlayer("Admin"); + admin.addAttachment(plugin, "sh2.delete-player-home", true); + + assertTrue(server.execute("delete-player-home", admin, "Nobody", "base").hasSucceeded()); + + assertTrue(admin.nextMessage().contains("No player by that name")); + } + + @Test + void eachCommandIsRefusedWithoutItsNodeAtTheCommandGate() { + PlayerMock target = addPlayer("Steve"); + HomeFixtures.persist(target, "base"); + + PlayerMock admin = addPlayer("Admin"); + admin.addAttachment(plugin, "sh2.delete-player-home", false); + + assertTrue(server.execute("delete-player-home", admin, "Steve", "base").hasSucceeded()); + + assertTrue(admin.nextMessage().contains("permission")); + assertEquals(1, new HomesDao(true).getAll(target.getUniqueId()).size()); + } + + @Test + void theInCodeGuardAlsoRefuses() { + PlayerMock target = addPlayer("Steve"); + HomeFixtures.persist(target, "base"); + + PlayerMock admin = addPlayer("Admin"); + admin.addAttachment(plugin, "sh2.delete-player-home", false); + + // Call the executor directly. Going through server.execute would be + // refused by plugin.yml's permission before onCommand is reached. + new DeletePlayerHome().onCommand( + admin, + Objects.requireNonNull(plugin.getCommand("delete-player-home")), + "delete-player-home", + new String[]{"Steve", "base"}); + + assertTrue(admin.nextMessage().contains("permission")); + assertEquals(1, new HomesDao(true).getAll(target.getUniqueId()).size()); + } + + @Test + void theV1AliasesWork() { + PlayerMock target = addPlayer("Steve"); + HomeFixtures.persist(target, "base"); + + PlayerMock admin = addPlayer("Admin"); + admin.addAttachment(plugin, "sh2.delete-player-home", true); + + assertTrue(server.execute("delhome-of", admin, "Steve", "base").hasSucceeded()); + + assertTrue(new HomesDao(true).getAll(target.getUniqueId()).isEmpty()); + } + + @Test + void theWrongNumberOfArgumentsShowsTheUsage() { + PlayerMock admin = addPlayer("Admin"); + admin.addAttachment(plugin, "sh2.move-player-home", true); + + assertTrue(server.execute("move-player-home", admin, "Steve").hasSucceeded()); + + assertTrue(admin.nextMessage().contains("Incorrect number of arguments")); + assertTrue(admin.nextMessage().contains("Usage: /move-player-home ")); + } + + @Test + void theTabCompleterOffersOnlinePlayerNamesFirst() { + addPlayer("Steve"); + PlayerMock admin = addPlayer("Admin"); + + List completions = new PlayerHomesTabCompleter().onTabComplete( + admin, + Objects.requireNonNull(plugin.getCommand("delete-player-home")), + "delete-player-home", + new String[]{"St"}); + + assertTrue(completions.contains("Steve")); + assertFalse(completions.contains("Admin")); + } + + @Test + void theTabCompleterOffersTheTargetsHomeNamesSecond() { + PlayerMock target = addPlayer("Steve"); + HomeFixtures.persist(target, "base"); + HomeFixtures.persist(target, "mine"); + target.disconnect(); + + PlayerMock admin = addPlayer("Admin"); + + List completions = new PlayerHomesTabCompleter().onTabComplete( + admin, + Objects.requireNonNull(plugin.getCommand("delete-player-home")), + "delete-player-home", + new String[]{"Steve", "b"}); + + assertEquals(List.of("base"), completions); + } + + @Test + void theInCodeGuardAlsoRefusesTheOtherTwoCommands() { + PlayerMock target = addPlayer("Steve"); + HomeFixtures.persist(target, "base"); + double beforeX = new HomesDao(true).getAll(target.getUniqueId()).get(0).getX(); + + PlayerMock admin = addPlayer("Admin"); + admin.addAttachment(plugin, "sh2.go-player-home", false); + admin.addAttachment(plugin, "sh2.move-player-home", false); + admin.teleport(new Location(overworld, 900, 70, 900)); + // So a regression that lets the teleport through fails on the assertion + // below rather than aborting on an unimplemented mock call. + plugin.getConfig().set("teleportSafety", false); + + new GoPlayerHome().onCommand(admin, + Objects.requireNonNull(plugin.getCommand("go-player-home")), + "go-player-home", new String[]{"Steve", "base"}); + assertTrue(admin.nextMessage().contains("permission")); + assertEquals(900.0, admin.getLocation().getX()); + + new MovePlayerHome().onCommand(admin, + Objects.requireNonNull(plugin.getCommand("move-player-home")), + "move-player-home", new String[]{"Steve", "base"}); + assertTrue(admin.nextMessage().contains("permission")); + assertEquals(beforeX, new HomesDao(true).getAll(target.getUniqueId()).get(0).getX()); + } + + @Test + void anUnknownHomeIsReportedByEachCommand() { + PlayerMock target = addPlayer("Steve"); + HomeFixtures.persist(target, "base"); + + PlayerMock admin = addPlayer("Admin"); + admin.addAttachment(plugin, "sh2.delete-player-home", true); + admin.addAttachment(plugin, "sh2.move-player-home", true); + admin.addAttachment(plugin, "sh2.go-player-home", true); + + assertTrue(server.execute("delete-player-home", admin, "Steve", "nope").hasSucceeded()); + assertUnknownHomeNamed(admin, "nope"); + + assertTrue(server.execute("move-player-home", admin, "Steve", "nope").hasSucceeded()); + assertUnknownHomeNamed(admin, "nope"); + + assertTrue(server.execute("go-player-home", admin, "Steve", "nope").hasSucceeded()); + assertUnknownHomeNamed(admin, "nope"); + + assertEquals(1, new HomesDao(true).getAll(target.getUniqueId()).size()); + } + + @Test + void theSuccessMessageNamesTheOwnerAndHomeCanonically() { + PlayerMock target = addPlayer("Steve"); + HomeFixtures.persist(target, "base"); + + PlayerMock admin = addPlayer("Admin"); + admin.addAttachment(plugin, "sh2.delete-player-home", true); + + // Both names typed in the wrong case. The reply must echo the stored + // spelling, not what was typed. + assertTrue(server.execute("delete-player-home", admin, "sTeVe", "BaSe").hasSucceeded()); + + String reply = admin.nextMessage(); + assertTrue(reply.contains("Steve"), reply); + assertTrue(reply.contains("base"), reply); + } + + /** + * Every command must name the home it looked for. "That home no longer + * exists" is the GUI's message, for a home that vanished mid-menu. + */ + private static void assertUnknownHomeNamed(PlayerMock admin, String typed) { + String message = admin.nextMessage(); + assertTrue(message.contains(typed), message); + assertFalse(message.contains("%s"), message); + } + + @Test + void withoutTheBypassNodeAnAdminCannotReachAnotherPlayersBlacklistedHome() { + TestPlayer owner = addPlayer("Steve"); + HomeFixtures.persist(HomeFixtures.home(owner, "hideout", new Location(nether, 33, 70, 33))); + HomeFixtures.blacklist("world_nether"); + owner.disconnect(); + + TestPlayer admin = adminAt(new Location(overworld, 0, 70, 0)); + admin.addAttachment(plugin, "sh2.bypass-blacklist", false); + + assertTrue(server.execute("go-player-home", admin, "Steve", "hideout").hasSucceeded()); + server.getScheduler().performTicks(100L); + + assertEquals("world", admin.getLocation().getWorld().getName()); + assertTrue(admin.nextMessage().contains("blacklisted")); + } + + @Test + void aBlacklistedTeleportCountsTheBlacklistedOutcome() { + TestPlayer owner = addPlayer("Steve"); + HomeFixtures.persist(HomeFixtures.home(owner, "hideout", new Location(nether, 33, 70, 33))); + HomeFixtures.blacklist("world_nether"); + owner.disconnect(); + + TestPlayer admin = adminAt(new Location(overworld, 0, 70, 0)); + admin.addAttachment(plugin, "sh2.bypass-blacklist", false); + + assertTrue(server.execute("go-player-home", admin, "Steve", "hideout").hasSucceeded()); + server.getScheduler().performTicks(100L); + + assertEquals(1, plugin.getUsageCounters().snapshot(UsageCounters.Family.TELEPORT_OUTCOME).get(UsageCounters.OUTCOME_BLACKLISTED)); + } + + @Test + void withTheBypassNodeAnAdminReachesAnotherPlayersBlacklistedHome() { + TestPlayer owner = addPlayer("Steve"); + HomeFixtures.persist(HomeFixtures.home(owner, "hideout", new Location(nether, 33, 70, 33))); + HomeFixtures.blacklist("world_nether"); + owner.disconnect(); + + TestPlayer admin = adminAt(new Location(overworld, 0, 70, 0)); + admin.addAttachment(plugin, "sh2.bypass-blacklist", true); + + assertTrue(server.execute("go-player-home", admin, "Steve", "hideout").hasSucceeded()); + server.getScheduler().performTicks(100L); + + assertEquals("world_nether", admin.getLocation().getWorld().getName()); + assertEquals(33.0, admin.getLocation().getX()); + } + + @Test + void withoutTheBypassNodeClickingABlacklistedHomeInTheAdminViewDoesNotTeleport() { + TestPlayer owner = addPlayer("Steve"); + HomeFixtures.persist(HomeFixtures.home(owner, "hideout", new Location(nether, 33, 70, 33))); + HomeFixtures.blacklist("world_nether"); + + TestPlayer admin = adminAt(new Location(overworld, 0, 70, 0)); + admin.addAttachment(plugin, "sh2.get-player-homes", true); + admin.addAttachment(plugin, "sh2.bypass-blacklist", false); + + assertTrue(server.execute("get-player-homes", admin, "Steve").hasSucceeded()); + clickFirstHome(admin); + server.getScheduler().performTicks(100L); + + assertEquals("world", admin.getLocation().getWorld().getName()); + } + + @Test + void withTheBypassNodeClickingABlacklistedHomeInTheAdminViewTeleports() { + TestPlayer owner = addPlayer("Steve"); + HomeFixtures.persist(HomeFixtures.home(owner, "hideout", new Location(nether, 33, 70, 33))); + HomeFixtures.blacklist("world_nether"); + + TestPlayer admin = adminAt(new Location(overworld, 0, 70, 0)); + admin.addAttachment(plugin, "sh2.get-player-homes", true); + admin.addAttachment(plugin, "sh2.bypass-blacklist", true); + + assertTrue(server.execute("get-player-homes", admin, "Steve").hasSucceeded()); + clickFirstHome(admin); + server.getScheduler().performTicks(100L); + + assertEquals("world_nether", admin.getLocation().getWorld().getName()); + } + + /** + * Left-click the first home of whatever list the player currently has open, + * routed through the live GuiSession the command created. + */ + private void clickFirstHome(TestPlayer player) { + GuiSession session = plugin.getGuiSessionMap().get(player.getUniqueId()); + HomesGui gui = (HomesGui) session.getActiveScreen(); + + gui.onClick(new InventoryClickEvent( + player.getOpenInventory(), + InventoryType.SlotType.CONTAINER, + 0, + ClickType.LEFT, + InventoryAction.PICKUP_ALL + ), session); + } + + /** + * An admin holding the two nodes every go-player-home test needs, standing + * where the caller says, with the teleport machinery set to resolve inside + * the ticks these tests advance. + */ + private TestPlayer adminAt(Location where) { + TestPlayer admin = addPlayer("Admin"); + admin.addAttachment(plugin, "sh2.go-player-home", true); + admin.addAttachment(plugin, "sh2.teleport", true); + admin.teleport(where); + plugin.getConfig().set("delay", 0); + plugin.getConfig().set("teleportSafety", false); + return admin; + } +} diff --git a/src/test/java/com/samleighton/sethomestwo/commands/SetMaxHomesTest.java b/src/test/java/com/samleighton/sethomestwo/commands/SetMaxHomesTest.java new file mode 100644 index 0000000..96e4236 --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/commands/SetMaxHomesTest.java @@ -0,0 +1,77 @@ +package com.samleighton.sethomestwo.commands; + +import com.samleighton.sethomestwo.support.ServerTestBase; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +import java.io.File; +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SetMaxHomesTest extends ServerTestBase { + + // SetMaxHomes reads and writes config.yml directly from disk rather than + // through plugin.getConfig(), so tests that need a particular maxHomesType + // write the file themselves. + private void useSingularMaxHomes() throws IOException { + File configFile = new File(plugin.getDataFolder(), "config.yml"); + YamlConfiguration config = YamlConfiguration.loadConfiguration(configFile); + config.set("maxHomesType", "singular"); + config.set("maxHomes", 5); + config.save(configFile); + } + + @Test + void theAliasWorks() throws IOException { + useSingularMaxHomes(); + PlayerMock player = addPlayer(); + player.addAttachment(plugin, "sh2.set-max-homes", true); + + server.execute("setmax", player, "7"); + + assertEquals(7, savedConfig().getInt("maxHomes")); + } + + @Test + void theUsageNamesTheAliasThatWasTyped() throws IOException { + useSingularMaxHomes(); + PlayerMock player = addPlayer(); + player.addAttachment(plugin, "sh2.set-max-homes", true); + + server.dispatchCommand(player, "setmax"); + + player.nextMessage(); + assertTrue(player.nextMessage().contains("Usage: /setmax")); + } + + // The shipped default-config.yml is in groups mode, where maxHomes is a + // section (admin: 5, user: 4) rather than a scalar. getString("maxHomes") + // returns null for a section, which used to be misread as "not configured". + @Test + void groupsModeAcceptsTheShippedDefaultConfig() { + PlayerMock player = addPlayer(); + player.addAttachment(plugin, "sh2.set-max-homes", true); + + server.execute("set-max-homes", player, "admin", "9"); + + assertEquals(9, savedConfig().getInt("maxHomes.admin")); + assertEquals(4, savedConfig().getInt("maxHomes.user")); + } + + @Test + void groupsModeRejectsAnUnknownGroup() { + PlayerMock player = addPlayer(); + player.addAttachment(plugin, "sh2.set-max-homes", true); + + server.execute("set-max-homes", player, "nope", "9"); + + assertTrue(player.nextMessage().contains("Group does not exist")); + } + + private YamlConfiguration savedConfig() { + return YamlConfiguration.loadConfiguration(new File(plugin.getDataFolder(), "config.yml")); + } +} diff --git a/src/test/java/com/samleighton/sethomestwo/dao/HomesDaoPlayerNameTest.java b/src/test/java/com/samleighton/sethomestwo/dao/HomesDaoPlayerNameTest.java new file mode 100644 index 0000000..c49e198 --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/dao/HomesDaoPlayerNameTest.java @@ -0,0 +1,109 @@ +package com.samleighton.sethomestwo.dao; + +import com.samleighton.sethomestwo.models.Home; +import com.samleighton.sethomestwo.support.HomeFixtures; +import com.samleighton.sethomestwo.support.ServerTestBase; +import com.samleighton.sethomestwo.utils.DatabaseUtil; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HomesDaoPlayerNameTest extends ServerTestBase { + + @Test + void theOwnerNameIsStoredWhenAHomeIsSaved() { + PlayerMock player = addPlayer("Steve"); + HomeFixtures.persist(player, "base"); + + assertEquals("Steve", new HomesDao().getAll(player.getUniqueId()).get(0).getPlayerName()); + } + + @Test + void aNameResolvesToItsOwnersUuid() { + PlayerMock player = addPlayer("Steve"); + HomeFixtures.persist(player, "base"); + + assertEquals(player.getUniqueId().toString(), new HomesDao().uuidForName("Steve")); + } + + @Test + void anUnknownNameResolvesToNull() { + assertNull(new HomesDao().uuidForName("Nobody")); + } + + @Test + void aRenameIsPickedUpByRefresh() { + PlayerMock player = addPlayer("Steve"); + HomeFixtures.persist(player, "base"); + + new HomesDao().refreshPlayerName(player.getUniqueId(), "Steven"); + + assertEquals(player.getUniqueId().toString(), new HomesDao().uuidForName("Steven")); + assertNull(new HomesDao().uuidForName("Steve")); + } + + @Test + void aNameSharedByTwoDistinctOwnersResolvesToNull() { + PlayerMock playerA = addPlayer("PlayerA"); + Home homeA = HomeFixtures.home(playerA, "base"); + homeA.setPlayerName("Steve"); + HomeFixtures.persist(homeA); + + PlayerMock playerB = addPlayer("PlayerB"); + Home homeB = HomeFixtures.home(playerB, "base"); + homeB.setPlayerName("Steve"); + HomeFixtures.persist(homeB); + + assertNull(new HomesDao().uuidForName("Steve")); + } + + @Test + void theMostRecentJoinerClaimsANameFromAStalePriorOwner() { + PlayerMock playerA = addPlayer("PlayerA"); + Home homeA = HomeFixtures.home(playerA, "base"); + homeA.setPlayerName("Steve"); + HomeFixtures.persist(homeA); + + PlayerMock playerB = addPlayer("PlayerB"); + Home homeB = HomeFixtures.home(playerB, "base"); + homeB.setPlayerName("Steve"); + HomeFixtures.persist(homeB); + + // PlayerB actually joins under the name "Steve", so it must win the + // name and PlayerA's stale rows must stop carrying it. + new HomesDao().refreshPlayerName(playerB.getUniqueId(), "Steve"); + + assertEquals(playerB.getUniqueId().toString(), new HomesDao().uuidForName("Steve")); + } + + @Test + void initTablesIsSafeToRunAgainAfterTheColumnAlreadyExists() { + Connection connection = plugin.getConnectionManager().getConnection("homes"); + + assertTrue(DatabaseUtil.initTables(connection)); + assertEquals(1, countPlayerNameColumns(connection)); + } + + private int countPlayerNameColumns(Connection connection) { + int count = 0; + + try (Statement statement = connection.createStatement(); + ResultSet rs = statement.executeQuery("pragma table_info(players_homes);")) { + while (rs.next()) { + if ("player_name".equalsIgnoreCase(rs.getString("name"))) count++; + } + } catch (SQLException e) { + throw new IllegalStateException("Could not read players_homes columns", e); + } + + return count; + } +} diff --git a/src/test/java/com/samleighton/sethomestwo/dao/HomesDaoTest.java b/src/test/java/com/samleighton/sethomestwo/dao/HomesDaoTest.java index 57286ba..28b66bb 100644 --- a/src/test/java/com/samleighton/sethomestwo/dao/HomesDaoTest.java +++ b/src/test/java/com/samleighton/sethomestwo/dao/HomesDaoTest.java @@ -24,6 +24,26 @@ class HomesDaoTest extends ServerTestBase { + @Test + void namesForReturnsEveryHomeNameWithItsStoredCasing() { + PlayerMock player = addPlayer(); + HomeFixtures.persist(player, "Base"); + HomeFixtures.persist(player, "shop"); + + List names = new HomesDao().namesFor(player.getUniqueId()); + + assertEquals(2, names.size()); + assertTrue(names.contains("Base")); + assertTrue(names.contains("shop")); + } + + @Test + void namesForIsEmptyForAPlayerWithNoHomes() { + PlayerMock player = addPlayer(); + + assertTrue(new HomesDao().namesFor(player.getUniqueId()).isEmpty()); + } + @Test void savedHomeComesBackFromGetAll() { PlayerMock player = addPlayer(); @@ -36,6 +56,17 @@ void savedHomeComesBackFromGetAll() { assertNotNull(homes.get(0).getId()); } + @Test + void getIsCaseInsensitive() { + PlayerMock player = addPlayer(); + HomeFixtures.persist(player, "base"); + + Home found = new HomesDao().get(player.getUniqueId(), "BaSe"); + + assertNotNull(found); + assertEquals("base", found.getName()); + } + @Test void getReturnsNullForUnknownName() { PlayerMock player = addPlayer(); @@ -188,6 +219,23 @@ void blacklistedDimensionStillAllowsTheAdminView() { assertTrue(home.getCanTeleport()); } + @Test + void countsCoverEveryPlayer() { + PlayerMock steve = addPlayer("Steve"); + PlayerMock alex = addPlayer("Alex"); + HomesDao dao = new HomesDao(); + + assertEquals(0, dao.countAll()); + assertEquals(0, dao.countPlayersWithHomes()); + + HomeFixtures.persist(steve, "a"); + HomeFixtures.persist(steve, "b"); + HomeFixtures.persist(alex, "c"); + + assertEquals(3, dao.countAll()); + assertEquals(2, dao.countPlayersWithHomes()); + } + /** * Captures what gets logged during {@code action}. The handler is always * removed afterward so it cannot leak into other tests. diff --git a/src/test/java/com/samleighton/sethomestwo/events/PlayerJoinTest.java b/src/test/java/com/samleighton/sethomestwo/events/PlayerJoinTest.java index 36429fb..64f703f 100644 --- a/src/test/java/com/samleighton/sethomestwo/events/PlayerJoinTest.java +++ b/src/test/java/com/samleighton/sethomestwo/events/PlayerJoinTest.java @@ -1,11 +1,15 @@ package com.samleighton.sethomestwo.events; +import com.samleighton.sethomestwo.dao.HomesDao; +import com.samleighton.sethomestwo.support.HomeFixtures; import com.samleighton.sethomestwo.support.ServerTestBase; import com.samleighton.sethomestwo.support.TestPlayer; import com.samleighton.sethomestwo.updates.UpdateChecker; +import net.kyori.adventure.text.Component; import org.bukkit.event.player.PlayerJoinEvent; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -19,10 +23,26 @@ void joiningPlayerWhoMaySeeNoticesIsToldAboutAnAvailableUpdate() { TestPlayer player = addPlayer(); player.addAttachment(plugin, UpdateChecker.NOTIFY_PERMISSION, true); - server.getPluginManager().callEvent(new PlayerJoinEvent(player, "")); + server.getPluginManager().callEvent(new PlayerJoinEvent(player, Component.empty())); String message = player.nextMessage(); assertNotNull(message, "expected the join listener to deliver the update notice"); assertTrue(message.contains("v1.3.0")); } + + @Test + void joiningRefreshesTheStoredNameOnExistingHomes() { + UpdateChecker checker = new UpdateChecker(plugin, "1.2.0", () -> null); + server.getPluginManager().registerEvents(new PlayerJoin(plugin, checker), plugin); + + TestPlayer player = addPlayer("Steve"); + HomeFixtures.persist(player, "base"); + // Simulate a stale name left over from before this join, as if the + // account had been seen under a different name previously. + new HomesDao().refreshPlayerName(player.getUniqueId(), "OldSteve"); + + server.getPluginManager().callEvent(new PlayerJoinEvent(player, Component.empty())); + + assertEquals(player.getUniqueId().toString(), new HomesDao().uuidForName("Steve")); + } } diff --git a/src/test/java/com/samleighton/sethomestwo/events/RightClickHomeItemTest.java b/src/test/java/com/samleighton/sethomestwo/events/RightClickHomeItemTest.java index 8b40357..a6ce3e7 100644 --- a/src/test/java/com/samleighton/sethomestwo/events/RightClickHomeItemTest.java +++ b/src/test/java/com/samleighton/sethomestwo/events/RightClickHomeItemTest.java @@ -8,6 +8,9 @@ import com.samleighton.sethomestwo.support.HomeFixtures; import com.samleighton.sethomestwo.support.ServerTestBase; import com.samleighton.sethomestwo.support.TestPlayer; +import java.util.HashMap; +import java.util.Map; +import net.kyori.adventure.text.Component; import org.bukkit.Material; import org.bukkit.NamespacedKey; import org.bukkit.event.block.Action; @@ -21,9 +24,6 @@ import org.bukkit.inventory.meta.ItemMeta; import org.junit.jupiter.api.Test; -import java.util.HashMap; -import java.util.Map; - import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -99,7 +99,7 @@ void anUntaggedCompassIsIgnored() { // Proves the click was dropped silently by the tag-presence guard, // not merely stopped by a different, noisier guard further down // (e.g. the ownership check, which also leaves activeScreen null). - player.assertNoMoreSaid(); + assertNull(player.nextMessage()); } @Test @@ -113,7 +113,7 @@ void aDifferentMaterialIsIgnored() { interact(player, Action.RIGHT_CLICK_AIR, taggedItem(Material.DIAMOND, player)); assertNull(sessionFor(player).getActiveScreen()); - player.assertNoMoreSaid(); + assertNull(player.nextMessage()); } @Test @@ -171,7 +171,7 @@ void anInventoryClickFromAPlayerWithNoSessionIsIgnored() { TestPlayer player = addTestPlayer("owner"); plugin.getGuiSessionMap().clear(); - player.openInventory(org.bukkit.Bukkit.createInventory(player, 9, "unrelated")); + player.openInventory(org.bukkit.Bukkit.createInventory(player, 9, Component.text("unrelated"))); InventoryClickEvent event = new InventoryClickEvent( player.getOpenInventory(), InventoryType.SlotType.CONTAINER, @@ -204,7 +204,7 @@ void anInventoryDragFromAPlayerWithNoSessionIsIgnored() { TestPlayer player = addTestPlayer("owner"); plugin.getGuiSessionMap().clear(); - player.openInventory(org.bukkit.Bukkit.createInventory(player, 9, "unrelated")); + player.openInventory(org.bukkit.Bukkit.createInventory(player, 9, Component.text("unrelated"))); InventoryDragEvent event = dragOn(player); server.getPluginManager().callEvent(event); diff --git a/src/test/java/com/samleighton/sethomestwo/gui/GuiSessionTest.java b/src/test/java/com/samleighton/sethomestwo/gui/GuiSessionTest.java index f2fc7a2..9fa84aa 100644 --- a/src/test/java/com/samleighton/sethomestwo/gui/GuiSessionTest.java +++ b/src/test/java/com/samleighton/sethomestwo/gui/GuiSessionTest.java @@ -3,6 +3,9 @@ import com.samleighton.sethomestwo.models.Home; import com.samleighton.sethomestwo.support.HomeFixtures; import com.samleighton.sethomestwo.support.ServerTestBase; +import java.util.HashMap; +import java.util.Map; +import net.kyori.adventure.text.Component; import org.bukkit.Bukkit; import org.bukkit.Material; import org.bukkit.event.inventory.ClickType; @@ -16,9 +19,6 @@ import org.junit.jupiter.api.Test; import org.mockbukkit.mockbukkit.entity.PlayerMock; -import java.util.HashMap; -import java.util.Map; - import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -68,7 +68,7 @@ void clickIsIgnoredWhenNoScreenIsActive() { PlayerMock player = addPlayer(); GuiSession session = new GuiSession(new HomesGui(player)); - InventoryClickEvent event = clickOn(player, Bukkit.createInventory(player, 9, "other"), 0); + InventoryClickEvent event = clickOn(player, Bukkit.createInventory(player, 9, Component.text("other")), 0); session.handleClick(event); assertFalse(event.isCancelled()); @@ -79,10 +79,10 @@ void clickInAForeignInventoryIsNeitherCancelledNorRouted() { PlayerMock player = addPlayer(); GuiSession session = new GuiSession(new HomesGui(player)); - RecordingScreen screen = new RecordingScreen(Bukkit.createInventory(player, 9, "active")); + RecordingScreen screen = new RecordingScreen(Bukkit.createInventory(player, 9, Component.text("active"))); session.setActiveScreen(screen); - InventoryClickEvent event = clickOn(player, Bukkit.createInventory(player, 9, "foreign"), 0); + InventoryClickEvent event = clickOn(player, Bukkit.createInventory(player, 9, Component.text("foreign")), 0); session.handleClick(event); assertFalse(event.isCancelled()); @@ -94,7 +94,7 @@ void clickInTheActiveScreenIsCancelledAndRouted() { PlayerMock player = addPlayer(); GuiSession session = new GuiSession(new HomesGui(player)); - Inventory inventory = Bukkit.createInventory(player, 9, "active"); + Inventory inventory = Bukkit.createInventory(player, 9, Component.text("active")); RecordingScreen screen = new RecordingScreen(inventory); session.setActiveScreen(screen); @@ -110,7 +110,7 @@ void dragIsIgnoredWhenNoScreenIsActive() { PlayerMock player = addPlayer(); GuiSession session = new GuiSession(new HomesGui(player)); - InventoryDragEvent event = dragOn(player, Bukkit.createInventory(player, 9, "other")); + InventoryDragEvent event = dragOn(player, Bukkit.createInventory(player, 9, Component.text("other"))); session.handleDrag(event); assertFalse(event.isCancelled()); @@ -120,9 +120,9 @@ void dragIsIgnoredWhenNoScreenIsActive() { void dragInAForeignInventoryIsNotCancelled() { PlayerMock player = addPlayer(); GuiSession session = new GuiSession(new HomesGui(player)); - session.setActiveScreen(new RecordingScreen(Bukkit.createInventory(player, 9, "active"))); + session.setActiveScreen(new RecordingScreen(Bukkit.createInventory(player, 9, Component.text("active")))); - InventoryDragEvent event = dragOn(player, Bukkit.createInventory(player, 9, "foreign")); + InventoryDragEvent event = dragOn(player, Bukkit.createInventory(player, 9, Component.text("foreign"))); session.handleDrag(event); assertFalse(event.isCancelled()); @@ -131,7 +131,7 @@ void dragInAForeignInventoryIsNotCancelled() { @Test void dragInTheActiveScreenIsCancelled() { PlayerMock player = addPlayer(); - Inventory inventory = Bukkit.createInventory(player, 9, "active"); + Inventory inventory = Bukkit.createInventory(player, 9, Component.text("active")); GuiSession session = new GuiSession(new HomesGui(player)); session.setActiveScreen(new RecordingScreen(inventory)); diff --git a/src/test/java/com/samleighton/sethomestwo/gui/HomeActionsGuiTest.java b/src/test/java/com/samleighton/sethomestwo/gui/HomeActionsGuiTest.java index 68d42ec..d67fdab 100644 --- a/src/test/java/com/samleighton/sethomestwo/gui/HomeActionsGuiTest.java +++ b/src/test/java/com/samleighton/sethomestwo/gui/HomeActionsGuiTest.java @@ -1,20 +1,27 @@ package com.samleighton.sethomestwo.gui; +import com.samleighton.sethomestwo.SetHomesTwo; import com.samleighton.sethomestwo.dao.HomesDao; +import com.samleighton.sethomestwo.datatypes.PersistentHome; +import com.samleighton.sethomestwo.datatypes.PersistentString; +import com.samleighton.sethomestwo.metrics.UsageCounters; import com.samleighton.sethomestwo.models.Home; import com.samleighton.sethomestwo.support.HomeFixtures; import com.samleighton.sethomestwo.support.ServerTestBase; import org.bukkit.Location; import org.bukkit.Material; +import org.bukkit.NamespacedKey; import org.bukkit.event.inventory.ClickType; import org.bukkit.event.inventory.InventoryAction; import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryType; import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; import org.junit.jupiter.api.Test; import org.mockbukkit.mockbukkit.entity.PlayerMock; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -41,6 +48,22 @@ private void click(HomeActionsGui gui, GuiSession session, PlayerMock player, in gui.onClick(event, session); } + /** + * The home carried by the item in the given slot of the session's homes list. + * Its canTeleport flag is what gates {@link Home#teleport}. + */ + private Home listedHome(GuiSession session, int slot) { + ItemStack item = session.getHomesGui().getInventory().getItem(slot); + assertNotNull(item); + assertNotNull(item.getItemMeta()); + + Home listed = item.getItemMeta().getPersistentDataContainer() + .get(new NamespacedKey(SetHomesTwo.instance(), "home"), new PersistentHome()); + assertNotNull(listed); + + return listed; + } + private HomeActionsGui openSubmenu(PlayerMock player, Home home, GuiSession session) { HomeActionsGui gui = new HomeActionsGui(player, home); session.setActiveScreen(gui); @@ -141,6 +164,41 @@ void moveWritesThePlayersCurrentLocation() { assertEquals(-150.0, reloaded.getZ()); } + @Test + void theRefreshedListKeepsABlacklistBypassHoldersHomeTeleportable() { + PlayerMock player = addPlayer(); + player.addAttachment(plugin, "sh2.bypass-blacklist", true); + Home home = HomeFixtures.persist(player, "base"); + HomeFixtures.blacklist(overworld.getName()); + + HomesGui homesGui = new HomesGui(player); + homesGui.setHomes(new HomesDao(true).getAll(player.getUniqueId())); + GuiSession session = new GuiSession(homesGui); + + HomeActionsGui gui = openSubmenu(player, home, session); + player.getInventory().setItemInMainHand(new ItemStack(Material.DIAMOND)); + click(gui, session, player, SLOT_ICON); + + assertTrue(listedHome(session, 0).getCanTeleport()); + } + + @Test + void theRefreshedListMarksABlacklistedHomeUnreachableWithoutTheNode() { + PlayerMock player = addPlayer(); + Home home = HomeFixtures.persist(player, "base"); + HomeFixtures.blacklist(overworld.getName()); + + HomesGui homesGui = new HomesGui(player); + homesGui.setHomes(new HomesDao().getAll(player.getUniqueId())); + GuiSession session = new GuiSession(homesGui); + + HomeActionsGui gui = openSubmenu(player, home, session); + player.getInventory().setItemInMainHand(new ItemStack(Material.DIAMOND)); + click(gui, session, player, SLOT_ICON); + + assertFalse(listedHome(session, 0).getCanTeleport()); + } + @Test void moveIntoABlacklistedDimensionIsRefusedAndWritesNothing() { PlayerMock player = addPlayer(); @@ -192,6 +250,72 @@ void setIconTakesTheHeldItem() { assertEquals(Material.DIAMOND.name(), new HomesDao().getById(player.getUniqueId(), home.getId()).getMaterial()); } + @Test + void everyManagementActionIsCounted() { + PlayerMock player = addPlayer(); + Home home = HomeFixtures.persist(player, "base"); + GuiSession session = new GuiSession(new HomesGui(player)); + + HomeActionsGui gui = openSubmenu(player, home, session); + click(gui, session, player, SLOT_DELETE); + click(gui, session, player, SLOT_CANCEL); + click(gui, session, player, SLOT_DELETE); + click(gui, session, player, SLOT_CONFIRM); + + var actions = plugin.getUsageCounters().snapshot(UsageCounters.Family.GUI_ACTION); + assertEquals(2, actions.get(HomeActionsGui.ACTION_DELETE)); + assertEquals(1, actions.get(HomeActionsGui.ACTION_CANCEL_DELETE)); + assertEquals(1, actions.get(HomeActionsGui.ACTION_CONFIRM_DELETE)); + } + + @Test + void backAndMoveAreCounted() { + PlayerMock player = addPlayer(); + Home home = HomeFixtures.persist(player, "base"); + GuiSession session = new GuiSession(new HomesGui(player)); + + HomeActionsGui gui = openSubmenu(player, home, session); + click(gui, session, player, SLOT_MOVE); + gui = openSubmenu(player, home, session); + click(gui, session, player, SLOT_BACK); + + var actions = plugin.getUsageCounters().snapshot(UsageCounters.Family.GUI_ACTION); + assertEquals(1, actions.get(HomeActionsGui.ACTION_MOVE)); + assertEquals(1, actions.get(HomeActionsGui.ACTION_BACK)); + } + + @Test + void anEmptySlotClickCountsNothing() { + PlayerMock player = addPlayer(); + Home home = HomeFixtures.persist(player, "base"); + GuiSession session = new GuiSession(new HomesGui(player)); + + HomeActionsGui gui = openSubmenu(player, home, session); + click(gui, session, player, 3); + + assertTrue(plugin.getUsageCounters().snapshot(UsageCounters.Family.GUI_ACTION).isEmpty()); + } + + @Test + void anUnknownActionTagCountsNothing() { + PlayerMock player = addPlayer(); + Home home = HomeFixtures.persist(player, "base"); + GuiSession session = new GuiSession(new HomesGui(player)); + + HomeActionsGui gui = openSubmenu(player, home, session); + + ItemStack item = new ItemStack(Material.PAPER); + ItemMeta meta = item.getItemMeta(); + assertNotNull(meta); + meta.getPersistentDataContainer().set(new NamespacedKey(SetHomesTwo.instance(), HomeActionsGui.ACTION_KEY_NAME), new PersistentString(), "bogus"); + item.setItemMeta(meta); + gui.getInventory().setItem(3, item); + + click(gui, session, player, 3); + + assertTrue(plugin.getUsageCounters().snapshot(UsageCounters.Family.GUI_ACTION).isEmpty()); + } + @Test void applyRenameRejectsABlankName() { PlayerMock player = addPlayer(); diff --git a/src/test/java/com/samleighton/sethomestwo/gui/HomesGuiClickTest.java b/src/test/java/com/samleighton/sethomestwo/gui/HomesGuiClickTest.java index bc5c0f3..e96d3ba 100644 --- a/src/test/java/com/samleighton/sethomestwo/gui/HomesGuiClickTest.java +++ b/src/test/java/com/samleighton/sethomestwo/gui/HomesGuiClickTest.java @@ -1,9 +1,12 @@ package com.samleighton.sethomestwo.gui; import com.samleighton.sethomestwo.dao.HomesDao; +import com.samleighton.sethomestwo.metrics.UsageCounters; import com.samleighton.sethomestwo.models.Home; import com.samleighton.sethomestwo.support.HomeFixtures; import com.samleighton.sethomestwo.support.ServerTestBase; +import com.samleighton.sethomestwo.support.TestPlayer; +import org.bukkit.ChatColor; import org.bukkit.event.inventory.ClickType; import org.bukkit.event.inventory.InventoryAction; import org.bukkit.event.inventory.InventoryClickEvent; @@ -11,6 +14,7 @@ import org.junit.jupiter.api.Test; import org.mockbukkit.mockbukkit.entity.PlayerMock; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNull; @@ -110,6 +114,29 @@ void rightClickOnTheAdminListDoesNotOpenTheSubmenu() { assertFalse(session.getActiveScreen() instanceof HomeActionsGui); } + @Test + void leftClickCountsAGuiTeleport() { + TestPlayer player = addTestPlayer("traveller"); + HomeFixtures.persist(player, "base"); + plugin.getConfig().set("teleportSafety", false); + + HomesGui gui = openOwnList(player); + click(gui, new GuiSession(gui), player, 0, ClickType.LEFT); + + assertEquals(1, plugin.getUsageCounters().snapshot(UsageCounters.Family.GUI_ACTION).get(UsageCounters.GUI_TELEPORT)); + } + + @Test + void rightClickIntoManagementCountsNoTeleport() { + PlayerMock player = addPlayer(); + HomeFixtures.persist(player, "base"); + + HomesGui gui = openOwnList(player); + click(gui, new GuiSession(gui), player, 0, ClickType.RIGHT); + + assertTrue(plugin.getUsageCounters().snapshot(UsageCounters.Family.GUI_ACTION).isEmpty()); + } + @Test void anEmptyHomeListClosesTheMenuAndExplainsWhy() { PlayerMock player = addPlayer(); @@ -118,6 +145,8 @@ void anEmptyHomeListClosesTheMenuAndExplainsWhy() { gui.setHomes(new HomesDao().getAll(player.getUniqueId())); gui.displayInventory(player); - assertTrue(player.nextMessage().contains("You have not created any homes yet.")); + String message = player.nextMessage(); + assertTrue(message.contains("You have not created any homes yet.")); + assertFalse(message.contains(ChatColor.RED.toString()), "having no homes is not an error"); } } diff --git a/src/test/java/com/samleighton/sethomestwo/gui/HomesGuiPaginationTest.java b/src/test/java/com/samleighton/sethomestwo/gui/HomesGuiPaginationTest.java index ea636a7..97605a6 100644 --- a/src/test/java/com/samleighton/sethomestwo/gui/HomesGuiPaginationTest.java +++ b/src/test/java/com/samleighton/sethomestwo/gui/HomesGuiPaginationTest.java @@ -2,9 +2,16 @@ import com.samleighton.sethomestwo.SetHomesTwo; import com.samleighton.sethomestwo.datatypes.PersistentHome; +import com.samleighton.sethomestwo.metrics.UsageCounters; import com.samleighton.sethomestwo.models.Home; import com.samleighton.sethomestwo.support.HomeFixtures; import com.samleighton.sethomestwo.support.ServerTestBase; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import org.bukkit.Material; import org.bukkit.NamespacedKey; import org.bukkit.event.inventory.ClickType; @@ -16,11 +23,6 @@ import org.junit.jupiter.api.Test; import org.mockbukkit.mockbukkit.entity.PlayerMock; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -64,7 +66,7 @@ private Set homeNames(Inventory inventory) { for (ItemStack item : inventory.getContents()) { if (item == null || item.getItemMeta() == null) continue; if (!item.getItemMeta().getPersistentDataContainer().has(key, new PersistentHome())) continue; - names.add(item.getItemMeta().getDisplayName()); + names.add(PlainTextComponentSerializer.plainText().serialize(Objects.requireNonNull(item.getItemMeta().displayName()))); } return names; @@ -92,6 +94,22 @@ private void clickSlot(HomesGui gui, GuiSession session, PlayerMock player, int gui.onClick(event, session); } + @Test + void pageTurnsAreCounted() { + PlayerMock player = addPlayer(); + HomesGui gui = new HomesGui(player); + GuiSession session = new GuiSession(gui); + gui.setHomes(homes(player, 46)); + gui.displayInventory(player); + + clickSlot(gui, session, player, NEXT_PAGE_SLOT); + clickSlot(gui, session, player, PREV_PAGE_SLOT); + + var actions = plugin.getUsageCounters().snapshot(UsageCounters.Family.GUI_ACTION); + assertEquals(1, actions.get(UsageCounters.GUI_PAGE_NEXT)); + assertEquals(1, actions.get(UsageCounters.GUI_PAGE_PREVIOUS)); + } + @Test void fortyFiveHomesFitOnASinglePage() { PlayerMock player = addPlayer(); diff --git a/src/test/java/com/samleighton/sethomestwo/importers/EssentialsXImporterTest.java b/src/test/java/com/samleighton/sethomestwo/importers/EssentialsXImporterTest.java new file mode 100644 index 0000000..348fa6e --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/importers/EssentialsXImporterTest.java @@ -0,0 +1,62 @@ +package com.samleighton.sethomestwo.importers; + +import com.samleighton.sethomestwo.dao.HomesDao; +import com.samleighton.sethomestwo.support.ServerTestBase; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class EssentialsXImporterTest extends ServerTestBase { + + private final EssentialsXImporter importer = new EssentialsXImporter(); + + private File userdataDir() { + File dir = new File(plugin.getDataFolder().getParentFile(), "Essentials/userdata"); + dir.mkdirs(); + return dir; + } + + private void writeUserFile(UUID owner, String lastAccountName, String homeName, String worldName) throws IOException { + YamlConfiguration yaml = new YamlConfiguration(); + if (lastAccountName != null) { + yaml.set("lastAccountName", lastAccountName); + } + String path = "homes." + homeName + "."; + yaml.set(path + "world", worldName); + yaml.set(path + "x", 0.0); + yaml.set(path + "y", 64.0); + yaml.set(path + "z", 0.0); + yaml.set(path + "pitch", 0.0); + yaml.set(path + "yaw", 0.0); + yaml.save(new File(userdataDir(), owner + ".yml")); + } + + @Test + void theImportedHomeGetsTheStoredAccountName() throws IOException { + UUID owner = UUID.randomUUID(); + writeUserFile(owner, "Steve", "base", "world"); + + ImportReport report = importer.run(false); + + assertEquals("Steve", new HomesDao().get(owner, "base").getPlayerName()); + assertEquals(1, report.namesResolved); + } + + @Test + void aUserFileWithNoLastAccountNameImportsWithNoNameAndNoWarning() throws IOException { + UUID owner = UUID.randomUUID(); + writeUserFile(owner, null, "base", "world"); + + ImportReport report = importer.run(false); + + assertNull(new HomesDao().get(owner, "base").getPlayerName()); + assertTrue(report.warnings.isEmpty()); + } +} diff --git a/src/test/java/com/samleighton/sethomestwo/importers/ImportReportTest.java b/src/test/java/com/samleighton/sethomestwo/importers/ImportReportTest.java new file mode 100644 index 0000000..eb1c6b1 --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/importers/ImportReportTest.java @@ -0,0 +1,33 @@ +package com.samleighton.sethomestwo.importers; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ImportReportTest { + + @Test + void theSummaryTellsARenameApartFromAHomeThatWasAlreadyImported() { + ImportReport report = new ImportReport(); + report.imported = 11; + report.renamed = 1; + report.skippedExisting = 3; + + String summary = report.summary(false); + + assertTrue(summary.contains("1 renamed"), summary); + assertTrue(summary.contains("3 skipped: already imported"), summary); + } + + @Test + void theSummaryKeepsItsShapeOnADryRun() { + ImportReport report = new ImportReport(); + report.imported = 11; + report.renamed = 1; + + String summary = report.summary(true); + + assertTrue(summary.startsWith("Would import 11 homes"), summary); + assertTrue(summary.contains("1 renamed"), summary); + } +} diff --git a/src/test/java/com/samleighton/sethomestwo/importers/SetHomesV1ImporterTest.java b/src/test/java/com/samleighton/sethomestwo/importers/SetHomesV1ImporterTest.java new file mode 100644 index 0000000..812b376 --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/importers/SetHomesV1ImporterTest.java @@ -0,0 +1,516 @@ +package com.samleighton.sethomestwo.importers; + +import com.samleighton.sethomestwo.dao.BlacklistDao; +import com.samleighton.sethomestwo.dao.HomesDao; +import com.samleighton.sethomestwo.models.Home; +import com.samleighton.sethomestwo.support.HomeFixtures; +import com.samleighton.sethomestwo.support.ServerTestBase; +import com.samleighton.sethomestwo.support.TestPlayer; +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.logging.Handler; +import java.util.logging.LogRecord; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SetHomesV1ImporterTest extends ServerTestBase { + + private final SetHomesV1Importer importer = new SetHomesV1Importer(); + + private File setHomesDir() { + File dir = new File(plugin.getDataFolder().getParentFile(), "SetHomes"); + dir.mkdirs(); + return dir; + } + + /** + * SetHomesV1Importer.run() returns early with only a warning when + * plugins/SetHomes/homes.yml is missing (see the existing code at + * src/main/java/com/samleighton/sethomestwo/importers/SetHomesV1Importer.java:28-31). + * Every test that wants the blacklist path to actually run needs this + * called first, even though these tests have no homes of their own. + */ + private void writeEmptyHomesFile() throws IOException { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.save(new File(setHomesDir(), "homes.yml")); + } + + private void writeBlacklist(String... worlds) throws IOException { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("blacklisted_worlds", List.of(worlds)); + yaml.save(new File(setHomesDir(), "world_blacklist.yml")); + } + + @Test + void dryRunReportsWorldsItWouldBlacklistAndWritesNothing() throws IOException { + writeEmptyHomesFile(); + writeBlacklist("world_nether"); + + ImportReport report = importer.run(true); + + assertEquals(1, report.blacklistImported); + assertEquals(0, report.blacklistSkippedExisting); + assertTrue(new BlacklistDao().getAll().isEmpty()); + } + + @Test + void confirmAddsEachBlacklistedWorldLowercased() throws IOException { + writeEmptyHomesFile(); + writeBlacklist("World_Nether"); + + ImportReport report = importer.run(false); + + assertEquals(1, report.blacklistImported); + assertTrue(new BlacklistDao().getAll().contains("world_nether")); + } + + @Test + void reRunningDoesNotDuplicateAnAlreadyBlacklistedWorld() throws IOException { + writeEmptyHomesFile(); + HomeFixtures.blacklist("world_nether"); + writeBlacklist("world_nether"); + + ImportReport report = importer.run(false); + + assertEquals(0, report.blacklistImported); + assertEquals(1, report.blacklistSkippedExisting); + assertEquals(1, new BlacklistDao().getAll().size()); + } + + @Test + void duplicatesWithinTheSourceFileAreOnlyAddedOnce() throws IOException { + writeEmptyHomesFile(); + writeBlacklist("world_nether", "world_nether"); + + ImportReport report = importer.run(false); + + assertEquals(1, report.blacklistImported); + assertEquals(1, report.blacklistSkippedExisting); + assertEquals(1, new BlacklistDao().getAll().size()); + } + + @Test + void aBlacklistedWorldThatDoesNotExistIsStoredWithAWarning() throws IOException { + writeEmptyHomesFile(); + writeBlacklist("world_the_void"); + + ImportReport report = importer.run(false); + + assertEquals(1, report.blacklistImported); + assertTrue(new BlacklistDao().getAll().contains("world_the_void")); + assertTrue(report.warnings.stream().anyMatch(w -> w.contains("world_the_void") && w.contains("/remove-from-blacklist"))); + } + + @Test + void missingWorldBlacklistFileIsNotAnError() throws IOException { + // homes.yml present, world_blacklist.yml genuinely absent - the case + // v1 servers hit constantly, since it shipped empty by default. + writeEmptyHomesFile(); + + ImportReport report = importer.run(true); + + assertEquals(0, report.blacklistImported); + assertEquals(0, report.blacklistSkippedExisting); + assertTrue(report.warnings.isEmpty()); + } + + @Test + void emptyBlacklistedWorldsListIsNotAnError() throws IOException { + writeEmptyHomesFile(); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("blacklisted_worlds", List.of()); + yaml.save(new File(setHomesDir(), "world_blacklist.yml")); + + ImportReport report = importer.run(true); + + assertEquals(0, report.blacklistImported); + assertEquals(0, report.blacklistSkippedExisting); + assertTrue(report.warnings.isEmpty()); + } + + @Test + void aHomeImportedForAPlayerTheServerHasSeenGetsThatPlayersName() throws IOException { + PlayerMock steve = addPlayer("Steve"); + steve.disconnect(); + writeHomesFile(steve.getUniqueId(), "base", "world"); + + importer.run(false); + + assertEquals("Steve", new HomesDao().get(steve.getUniqueId(), "base").getPlayerName()); + } + + @Test + void aHomeImportedForAPlayerTheServerHasNeverSeenGetsNoNameAndNoWarning() throws IOException { + UUID neverSeen = UUID.randomUUID(); + writeHomesFile(neverSeen, "base", "world"); + + ImportReport report = importer.run(false); + + assertNull(new HomesDao().get(neverSeen, "base").getPlayerName()); + assertTrue(report.warnings.isEmpty()); + } + + @Test + void dryRunReportsNamesResolvedWithoutWritingAnything() throws IOException { + PlayerMock steve = addPlayer("Steve"); + steve.disconnect(); + writeHomesFile(steve.getUniqueId(), "base", "world"); + + ImportReport report = importer.run(true); + + assertEquals(1, report.namesResolved); + assertNull(new HomesDao().get(steve.getUniqueId(), "base")); + } + + private void writeHomesFile(UUID owner, String homeName, String worldName) throws IOException { + YamlConfiguration yaml = new YamlConfiguration(); + String path = "allNamedHomes." + owner + "." + homeName + "."; + yaml.set(path + "world", worldName); + yaml.set(path + "x", 0.0); + yaml.set(path + "y", 64.0); + yaml.set(path + "z", 0.0); + yaml.set(path + "pitch", 0.0); + yaml.set(path + "yaw", 0.0); + yaml.save(new File(setHomesDir(), "homes.yml")); + } + + @Test + void directlyMappedSettingsAreReportedWithBothKeys() throws IOException { + writeEmptyHomesFile(); + writeV1Config(v1 -> { + v1.set("tp-delay", 5); + v1.set("tp-cancelOnMove", true); + }); + + ImportReport report = importer.run(true); + + assertTrue(report.configNotes.stream().anyMatch(n -> n.contains("tp-delay") && n.contains("delay"))); + assertTrue(report.configNotes.stream().anyMatch(n -> n.contains("tp-cancelOnMove") && n.contains("cancelOnMove"))); + } + + @Test + void tpCooldownIsCalledOutAsHavingNoEquivalent() throws IOException { + writeEmptyHomesFile(); + writeV1Config(v1 -> v1.set("tp-cooldown", 30)); + + ImportReport report = importer.run(true); + + assertTrue(report.configNotes.stream().anyMatch(n -> n.contains("tp-cooldown") && n.contains("no v2 equivalent"))); + } + + @Test + void v1MessagesAreShownWithTheirColourCodesAsAmpersands() throws IOException { + writeEmptyHomesFile(); + writeV1Config(v1 -> { + v1.set("max-homes-msg", "§4You have reached the maximum amount of saved homes!"); + v1.set("tp-cancelOnMove-msg", "§kMovement detected!"); + }); + + ImportReport report = importer.run(true); + + for (String note : report.configNotes) { + assertFalse(note.contains("§"), "section sign leaked into: " + note); + } + assertTrue(report.configNotes.stream().anyMatch(n -> + n.contains("maxHomesReached") && n.contains("&4You have reached the maximum amount of saved homes!"))); + assertTrue(report.configNotes.stream().anyMatch(n -> + n.contains("movedWhileTeleporting") && n.contains("&kMovement detected!"))); + } + + @Test + void aColourCodedV1MessageSaysWhereTheExactOriginalLives() throws IOException { + writeEmptyHomesFile(); + writeV1Config(v1 -> v1.set("max-homes-msg", "§4Too many homes!")); + + ImportReport report = importer.run(true); + + assertTrue(report.configNotes.stream().anyMatch(n -> + n.contains("maxHomesReached") && n.contains("plugins/SetHomes/config.yml"))); + } + + @Test + void aPlainV1MessageIsShownAsIsWithoutTheColourCodeCaveat() throws IOException { + writeEmptyHomesFile(); + writeV1Config(v1 -> v1.set("max-homes-msg", "Too many homes!")); + + ImportReport report = importer.run(true); + + String note = report.configNotes.stream().filter(n -> n.contains("maxHomesReached")).findFirst().orElseThrow(); + assertTrue(note.contains("'Too many homes!'")); + assertFalse(note.contains("plugins/SetHomes/config.yml")); + } + + @Test + void aZeroMaxHomesGroupIsDescribedAsUnlimitedNotZero() throws IOException { + writeEmptyHomesFile(); + writeV1Config(v1 -> v1.set("max-homes.default", 0)); + + ImportReport report = importer.run(true); + + String note = report.configNotes.stream() + .filter(n -> n.contains("default")) + .findFirst() + .orElseThrow(); + assertTrue(note.contains("unlimited")); + assertFalse(note.matches(".*\\bmaxHomes\\.default:\\s*0\\b.*")); + } + + @Test + void aNonZeroMaxHomesGroupIsReportedWithItsNumber() throws IOException { + writeEmptyHomesFile(); + writeV1Config(v1 -> v1.set("max-homes.vip", 6)); + + ImportReport report = importer.run(true); + + assertTrue(report.configNotes.stream().anyMatch(n -> n.contains("vip") && n.contains("6"))); + } + + @Test + void missingV1ConfigFileProducesNoNotes() throws IOException { + writeEmptyHomesFile(); + + ImportReport report = importer.run(true); + + assertTrue(report.configNotes.isEmpty()); + } + + @Test + void theConfigReportNeverWritesToConfigYml() throws IOException { + writeEmptyHomesFile(); + writeV1Config(v1 -> v1.set("tp-delay", 5)); + long before = new File(plugin.getDataFolder(), "config.yml").lastModified(); + + importer.run(false); + + assertEquals(before, new File(plugin.getDataFolder(), "config.yml").lastModified()); + } + + // Case-only duplicates from v1: 'base' and 'Base' were two homes there. + + private final YamlConfiguration v1Homes = new YamlConfiguration(); + + // Adds one entry to allNamedHomes. Distinct x values tell the rows apart. + private void addV1Home(UUID owner, String homeName, String worldName, double x) { + String path = "allNamedHomes." + owner + "." + homeName + "."; + v1Homes.set(path + "world", worldName); + v1Homes.set(path + "x", x); + v1Homes.set(path + "y", 64.0); + v1Homes.set(path + "z", 0.0); + v1Homes.set(path + "pitch", 0.0); + v1Homes.set(path + "yaw", 0.0); + } + + // Adds the player's unnamed v1 home, which imports as 'default'. + private void addV1UnnamedHome(UUID owner, double x) { + String path = "unknownHomes." + owner + "."; + v1Homes.set(path + "world", "world"); + v1Homes.set(path + "x", x); + v1Homes.set(path + "y", 64.0); + v1Homes.set(path + "z", 0.0); + v1Homes.set(path + "pitch", 0.0); + v1Homes.set(path + "yaw", 0.0); + } + + private void saveV1Homes() throws IOException { + v1Homes.save(new File(setHomesDir(), "homes.yml")); + } + + private String counts(ImportReport report) { + return String.format("imported=%d renamed=%d skippedExisting=%d skippedWorldMissing=%d failed=%d namesResolved=%d", + report.imported, report.renamed, report.skippedExisting, + report.skippedWorldMissing, report.failed, report.namesResolved); + } + + private List homeNamesOf(UUID owner) { + return new HomesDao().getAll(owner).stream().map(Home::getName).toList(); + } + + @Test + void aCaseOnlyDuplicateIsImportedUnderAFreeNameRatherThanDropped() throws IOException { + UUID owner = UUID.randomUUID(); + addV1Home(owner, "base", "world", 10.0); + addV1Home(owner, "Base", "world", 20.0); + saveV1Homes(); + + ImportReport report = importer.run(false); + + assertEquals(2, report.imported); + assertEquals(1, report.renamed); + assertEquals(0, report.skippedExisting); + assertEquals(List.of("base", "Base2"), homeNamesOf(owner)); + } + + @Test + void theRenamedHomeKeepsItsOwnLocation() throws IOException { + UUID owner = UUID.randomUUID(); + addV1Home(owner, "base", "world", 10.0); + addV1Home(owner, "Base", "world", 20.0); + saveV1Homes(); + + importer.run(false); + + HomesDao dao = new HomesDao(); + assertEquals(10.0, dao.get(owner, "base").getX()); + assertEquals(20.0, dao.get(owner, "Base2").getX()); + } + + @Test + void theRenameIsReportedWithBothTheOldAndTheNewName() throws IOException { + UUID owner = UUID.randomUUID(); + addV1Home(owner, "base", "world", 10.0); + addV1Home(owner, "Base", "world", 20.0); + saveV1Homes(); + + ImportReport report = importer.run(false); + + assertTrue(report.warnings.stream().anyMatch(w -> w.contains("'Base'") && w.contains("'Base2'")), + "no warning naming the old and new name, got: " + report.warnings); + } + + @Test + void theRenameIsRecordedInTheServerLog() throws IOException { + UUID owner = UUID.randomUUID(); + addV1Home(owner, "base", "world", 10.0); + addV1Home(owner, "Base", "world", 20.0); + saveV1Homes(); + + List logged = new ArrayList<>(); + Handler capture = new Handler() { + @Override public void publish(LogRecord record) { logged.add(record.getMessage()); } + @Override public void flush() { } + @Override public void close() { } + }; + Bukkit.getLogger().addHandler(capture); + try { + importer.run(false); + } finally { + Bukkit.getLogger().removeHandler(capture); + } + + assertTrue(logged.stream().anyMatch(m -> m.contains("'Base'") && m.contains("'Base2'")), + "the rename was not logged, got: " + logged); + } + + @Test + void aGenuineReImportRenamesNothingAndImportsNothing() throws IOException { + UUID owner = UUID.randomUUID(); + addV1Home(owner, "base", "world", 10.0); + addV1Home(owner, "Base", "world", 20.0); + saveV1Homes(); + importer.run(false); + + ImportReport second = importer.run(false); + + assertEquals(0, second.imported); + assertEquals(0, second.renamed); + assertEquals(2, second.skippedExisting); + assertEquals(2, new HomesDao().getAll(owner).size()); + } + + @Test + void theDryRunReportsTheSameNumbersAsTheConfirmThatFollowsIt() throws IOException { + PlayerMock steve = addPlayer("Steve"); + steve.disconnect(); + addV1Home(steve.getUniqueId(), "base", "world", 10.0); + addV1Home(steve.getUniqueId(), "Base", "world", 20.0); + addV1Home(steve.getUniqueId(), "gone", "world_deleted", 30.0); + saveV1Homes(); + + ImportReport dryRun = importer.run(true); + ImportReport confirm = importer.run(false); + + assertEquals(counts(dryRun), counts(confirm)); + } + + @Test + void aDryRunDetectsACaseOnlyDuplicateWithoutWritingAnything() throws IOException { + UUID owner = UUID.randomUUID(); + addV1Home(owner, "base", "world", 10.0); + addV1Home(owner, "Base", "world", 20.0); + saveV1Homes(); + + ImportReport report = importer.run(true); + + assertEquals(1, report.renamed); + assertTrue(new HomesDao().getAll(owner).isEmpty()); + } + + @Test + void noHomeIsLostWhenTheDisambiguatedNameIsItselfTakenLaterInTheFile() throws IOException { + UUID owner = UUID.randomUUID(); + addV1Home(owner, "base", "world", 10.0); + addV1Home(owner, "Base", "world", 20.0); + addV1Home(owner, "base2", "world", 30.0); + saveV1Homes(); + + importer.run(false); + + List imported = new HomesDao().getAll(owner).stream().map(Home::getX).sorted().toList(); + assertEquals(List.of(10.0, 20.0, 30.0), imported); + List lowered = homeNamesOf(owner).stream().map(String::toLowerCase).distinct().toList(); + assertEquals(3, lowered.size(), "names must stay unique ignoring case, got: " + homeNamesOf(owner)); + } + + @Test + void anUnnamedHomeSurvivesAPlayerAlreadyHavingAHomeCalledDefault() throws IOException { + UUID owner = UUID.randomUUID(); + addV1Home(owner, "default", "world", 10.0); + addV1UnnamedHome(owner, 20.0); + saveV1Homes(); + + ImportReport report = importer.run(false); + + assertEquals(2, report.imported); + assertEquals(1, report.renamed); + assertEquals(2, new HomesDao().getAll(owner).size()); + } + + @Test + void theFreeNameSearchKeepsGoingPastANameAlreadyInTheDatabase() throws IOException { + PlayerMock owner = addPlayer(); + HomeFixtures.persist(owner, "Base2"); + addV1Home(owner.getUniqueId(), "base", "world", 10.0); + addV1Home(owner.getUniqueId(), "Base", "world", 20.0); + saveV1Homes(); + + importer.run(false); + + assertEquals(List.of("Base2", "base", "Base3"), homeNamesOf(owner.getUniqueId())); + } + + @Test + void bothHomesOfACaseOnlyPairAreReachableByCommand() throws IOException { + TestPlayer traveller = addTestPlayer("traveller"); + plugin.getConfig().set("teleportSafety", false); + plugin.getConfig().set("delay", 0); + addV1Home(traveller.getUniqueId(), "base", "world", 10.0); + addV1Home(traveller.getUniqueId(), "Base", "world", 20.0); + saveV1Homes(); + importer.run(false); + + server.execute("go-home", traveller, "base").assertSucceeded(); + server.getScheduler().performTicks(100L); + assertEquals(10, traveller.getLocation().getBlockX()); + + server.execute("go-home", traveller, "Base2").assertSucceeded(); + server.getScheduler().performTicks(100L); + assertEquals(20, traveller.getLocation().getBlockX()); + } + + private void writeV1Config(java.util.function.Consumer body) throws IOException { + YamlConfiguration yaml = new YamlConfiguration(); + body.accept(yaml); + yaml.save(new File(setHomesDir(), "config.yml")); + } +} diff --git a/src/test/java/com/samleighton/sethomestwo/importers/SetHomesV1SourceUntouchedTest.java b/src/test/java/com/samleighton/sethomestwo/importers/SetHomesV1SourceUntouchedTest.java new file mode 100644 index 0000000..62a3e3f --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/importers/SetHomesV1SourceUntouchedTest.java @@ -0,0 +1,142 @@ +package com.samleighton.sethomestwo.importers; + +import com.samleighton.sethomestwo.support.ServerTestBase; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The migration is reversible only because v2 never writes, renames or deletes + * anything under plugins/SetHomes/. Put the old jar back and the server is as + * it was. Nothing else in the suite holds the importer to that. + */ +class SetHomesV1SourceUntouchedTest extends ServerTestBase { + + private final SetHomesV1Importer importer = new SetHomesV1Importer(); + + private File v1Dir; + + @BeforeEach + void writeV1Source() throws IOException { + v1Dir = new File(plugin.getDataFolder().getParentFile(), "SetHomes"); + if (!v1Dir.isDirectory() && !v1Dir.mkdirs()) + throw new IllegalStateException("could not create " + v1Dir); + + writeHomes(); + writeBlacklist(); + writeConfig(); + } + + @Test + void aDryRunLeavesEveryV1FileExactlyAsItWas() throws IOException { + Map before = fingerprint(v1Dir); + + importer.run(true); + + assertEquals(before, fingerprint(v1Dir), + "a preview that writes to v1's folder is not a preview"); + } + + @Test + void aConfirmedImportLeavesEveryV1FileExactlyAsItWas() throws IOException { + Map before = fingerprint(v1Dir); + + ImportReport report = importer.run(false); + + // Without this the test would also pass for an importer that did nothing. + assertTrue(report.imported > 0, "the import should have brought homes across"); + assertTrue(report.renamed > 0, "the fixture holds a case-only duplicate"); + assertTrue(report.skippedWorldMissing > 0, "the fixture holds a home in a missing world"); + + assertEquals(before, fingerprint(v1Dir), + "rolling back to v1 depends on its files being untouched"); + } + + /** + * Path to SHA-256 for every file under the directory. Comparing the whole + * map catches an edited file, a new one and a deleted one alike. + */ + private Map fingerprint(File dir) throws IOException { + Map digests = new TreeMap<>(); + File[] entries = dir.listFiles(); + if (entries == null) throw new IllegalStateException("not a directory: " + dir); + + for (File entry : entries) { + if (entry.isDirectory()) { + // Recorded in its own right, or an added empty directory would + // contribute no entries and slip through unnoticed. + digests.put(entry.getName() + "/", "directory"); + fingerprint(entry).forEach((path, digest) -> digests.put(entry.getName() + "/" + path, digest)); + } else { + digests.put(entry.getName(), sha256(entry)); + } + } + return digests; + } + + private String sha256(File file) throws IOException { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(file.toPath())); + StringBuilder hex = new StringBuilder(); + for (byte b : digest) hex.append(String.format("%02x", b)); + return hex.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is required of every JVM", e); + } + } + + /** + * The cases that make the importer work hardest, so the test is not proving + * read-only behavior on a file the importer barely looks at: a home in a + * world that no longer exists, a case-only duplicate name, an unnamed home, + * and a player the server has never seen. + */ + private void writeHomes() throws IOException { + String owner = UUID.randomUUID().toString(); + String stranger = UUID.randomUUID().toString(); + + YamlConfiguration yaml = new YamlConfiguration(); + home(yaml, "allNamedHomes." + owner + ".base", "world"); + home(yaml, "allNamedHomes." + owner + ".Base", "world"); + home(yaml, "allNamedHomes." + owner + ".nether", "world_nether"); + home(yaml, "allNamedHomes." + owner + ".plotworld", "creative"); + home(yaml, "unknownHomes." + stranger, "world"); + yaml.save(new File(v1Dir, "homes.yml")); + } + + private void home(YamlConfiguration yaml, String path, String world) { + yaml.set(path + ".world", world); + yaml.set(path + ".x", 1.5); + yaml.set(path + ".y", 64.0); + yaml.set(path + ".z", -2.5); + yaml.set(path + ".pitch", 12.0); + yaml.set(path + ".yaw", 45.0); + } + + private void writeBlacklist() throws IOException { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("blacklisted_worlds", List.of("world_the_end")); + yaml.save(new File(v1Dir, "world_blacklist.yml")); + } + + private void writeConfig() throws IOException { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("tp-delay", 5); + yaml.set("tp-cancelOnMove", true); + yaml.set("max-homes.default", 3); + yaml.save(new File(v1Dir, "config.yml")); + } +} diff --git a/src/test/java/com/samleighton/sethomestwo/metrics/BucketsTest.java b/src/test/java/com/samleighton/sethomestwo/metrics/BucketsTest.java new file mode 100644 index 0000000..e48fdc9 --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/metrics/BucketsTest.java @@ -0,0 +1,43 @@ +package com.samleighton.sethomestwo.metrics; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class BucketsTest { + + @Test + void delayBuckets() { + assertEquals("0", Buckets.delay(0)); + assertEquals("0", Buckets.delay(-1)); + assertEquals("1-3", Buckets.delay(1)); + assertEquals("1-3", Buckets.delay(3)); + assertEquals("4-10", Buckets.delay(4)); + assertEquals("4-10", Buckets.delay(10)); + assertEquals("10+", Buckets.delay(11)); + } + + @Test + void homesPerServerBuckets() { + assertEquals("0", Buckets.homesPerServer(0)); + assertEquals("1-50", Buckets.homesPerServer(1)); + assertEquals("1-50", Buckets.homesPerServer(50)); + assertEquals("51-500", Buckets.homesPerServer(51)); + assertEquals("51-500", Buckets.homesPerServer(500)); + assertEquals("501-5000", Buckets.homesPerServer(501)); + assertEquals("501-5000", Buckets.homesPerServer(5000)); + assertEquals("5000+", Buckets.homesPerServer(5001)); + } + + @Test + void homesPerPlayerBuckets() { + assertEquals("0", Buckets.homesPerPlayer(0, 0)); + assertEquals("1", Buckets.homesPerPlayer(3, 3)); + assertEquals("1", Buckets.homesPerPlayer(5, 3)); + assertEquals("2-3", Buckets.homesPerPlayer(6, 3)); + assertEquals("2-3", Buckets.homesPerPlayer(11, 3)); + assertEquals("4-10", Buckets.homesPerPlayer(12, 3)); + assertEquals("4-10", Buckets.homesPerPlayer(30, 3)); + assertEquals("10+", Buckets.homesPerPlayer(33, 3)); + } +} diff --git a/src/test/java/com/samleighton/sethomestwo/metrics/CommandUsageListenerTest.java b/src/test/java/com/samleighton/sethomestwo/metrics/CommandUsageListenerTest.java new file mode 100644 index 0000000..4ffbec2 --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/metrics/CommandUsageListenerTest.java @@ -0,0 +1,96 @@ +package com.samleighton.sethomestwo.metrics; + +import com.samleighton.sethomestwo.support.ServerTestBase; +import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import org.bukkit.event.server.ServerCommandEvent; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +import java.util.Map; + +import static com.samleighton.sethomestwo.metrics.UsageCounters.Family.ALIAS; +import static com.samleighton.sethomestwo.metrics.UsageCounters.Family.COMMAND; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CommandUsageListenerTest extends ServerTestBase { + + private Map commands() { + return plugin.getUsageCounters().snapshot(COMMAND); + } + + private Map aliases() { + return plugin.getUsageCounters().snapshot(ALIAS); + } + + private void playerTypes(String line) { + PlayerMock player = addPlayer(); + server.getPluginManager().callEvent(new PlayerCommandPreprocessEvent(player, line)); + } + + @Test + void aCanonicalCommandCountsUnderItsOwnName() { + playerTypes("/go-home base"); + + assertEquals(1, commands().get("go-home")); + assertEquals(1, aliases().get("go-home")); + } + + @Test + void aDispatchedCommandIsCounted() { + PlayerMock player = addPlayer(); + player.performCommand("go-home base"); + + assertEquals(1, commands().get("go-home")); + } + + @Test + void anAliasCountsUnderTheCanonicalNameAndTheTypedAlias() { + playerTypes("/sethome base"); + + assertEquals(1, commands().get("create-home")); + assertEquals(1, aliases().get("sethome")); + assertTrue(!aliases().containsKey("create-home")); + } + + @Test + void theNamespacedFormCountsOnceWithoutTheNamespace() { + playerTypes("/sethomestwo:home base"); + + assertEquals(1, commands().get("go-home")); + assertEquals(1, aliases().get("home")); + assertEquals(1, commands().size()); + } + + @Test + void caseAndSurroundingSpacesDoNotMatter() { + playerTypes(" /Go-Home base "); + + assertEquals(1, commands().get("go-home")); + } + + @Test + void aForeignCommandCountsNothing() { + playerTypes("/say hello"); + playerTypes("/notacommandatall"); + playerTypes("/"); + + assertTrue(commands().isEmpty()); + assertTrue(aliases().isEmpty()); + } + + @Test + void consoleCommandsCountToo() { + server.getPluginManager().callEvent(new ServerCommandEvent(server.getConsoleSender(), "get-player-homes Steve")); + + assertEquals(1, commands().get("get-player-homes")); + } + + @Test + void theListenerNeverThrowsOnAnOddLine() { + server.getPluginManager().callEvent(new ServerCommandEvent(server.getConsoleSender(), "")); + server.getPluginManager().callEvent(new ServerCommandEvent(server.getConsoleSender(), " ")); + + assertTrue(commands().isEmpty()); + } +} diff --git a/src/test/java/com/samleighton/sethomestwo/metrics/ErrorsTest.java b/src/test/java/com/samleighton/sethomestwo/metrics/ErrorsTest.java new file mode 100644 index 0000000..688cbf4 --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/metrics/ErrorsTest.java @@ -0,0 +1,52 @@ +package com.samleighton.sethomestwo.metrics; + +import com.samleighton.sethomestwo.support.ServerTestBase; +import com.samleighton.sethomestwo.utils.DatabaseUtil; +import org.junit.jupiter.api.Test; + +import java.sql.Connection; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ErrorsTest extends ServerTestBase { + + private Map errors() { + return plugin.getUsageCounters().snapshot(UsageCounters.Family.ERROR); + } + + private Connection homes() { + return plugin.getConnectionManager().getConnection("homes"); + } + + @Test + void aFailedWriteCountsAsSqlWrite() { + assertFalse(DatabaseUtil.execute(homes(), "insert into no_such_table values (1)")); + assertEquals(-1, DatabaseUtil.executeUpdate(homes(), "update no_such_table set x = 1")); + + assertEquals(2, errors().get(Errors.SQL_WRITE)); + assertFalse(errors().containsKey(Errors.SQL_READ)); + } + + @Test + void aFailedReadCountsAsSqlRead() { + assertNull(DatabaseUtil.fetch(homes(), "select * from no_such_table")); + + assertEquals(1, errors().get(Errors.SQL_READ)); + } + + @Test + void successfulStatementsCountNothing() { + assertTrue(DatabaseUtil.execute(homes(), "select 1")); + assertTrue(errors().isEmpty()); + } + + @Test + void theStaticHookLandsInTheErrorFamily() { + Errors.count(Errors.DB_CONNECT); + assertEquals(1, errors().get(Errors.DB_CONNECT)); + } +} diff --git a/src/test/java/com/samleighton/sethomestwo/metrics/MetricsReporterTest.java b/src/test/java/com/samleighton/sethomestwo/metrics/MetricsReporterTest.java new file mode 100644 index 0000000..586fc2f --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/metrics/MetricsReporterTest.java @@ -0,0 +1,189 @@ +package com.samleighton.sethomestwo.metrics; + +import com.samleighton.sethomestwo.support.ServerTestBase; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MetricsReporterTest extends ServerTestBase { + + @Test + void aZeroPluginIdNeverStartsTheReporter() { + AtomicInteger built = new AtomicInteger(); + MetricsReporter reporter = new MetricsReporter(plugin, 0, () -> true, counters -> { + built.incrementAndGet(); + return () -> {}; + }); + + reporter.startLater(); + server.getScheduler().performTicks(MetricsReporter.STARTUP_DELAY_TICKS + 5); + + assertEquals(0, built.get()); + assertFalse(reporter.isRunning()); + } + + @Test + void disabledInConfigNeverBuildsTheReporter() { + AtomicInteger built = new AtomicInteger(); + MetricsReporter reporter = new MetricsReporter(plugin, 1, () -> false, counters -> { + built.incrementAndGet(); + return () -> {}; + }); + + reporter.startLater(); + server.getScheduler().performTicks(MetricsReporter.STARTUP_DELAY_TICKS + 5); + + assertEquals(0, built.get()); + assertFalse(reporter.isRunning()); + } + + @Test + void enabledInConfigBuildsTheReporterOnceAfterTheDelay() { + AtomicInteger built = new AtomicInteger(); + MetricsReporter reporter = new MetricsReporter(plugin, 1, () -> true, counters -> { + built.incrementAndGet(); + return () -> {}; + }); + + reporter.startLater(); + server.getScheduler().performTicks(MetricsReporter.STARTUP_DELAY_TICKS - 1); + assertFalse(reporter.isRunning()); + + server.getScheduler().performTicks(5); + assertEquals(1, built.get()); + assertTrue(reporter.isRunning()); + } + + @Test + void theFlagIsReadWhenTheTaskFiresNotWhenScheduled() { + AtomicBoolean enabled = new AtomicBoolean(true); + MetricsReporter reporter = new MetricsReporter(plugin, 1, enabled::get, counters -> () -> {}); + + reporter.startLater(); + enabled.set(false); + server.getScheduler().performTicks(MetricsReporter.STARTUP_DELAY_TICKS + 5); + + assertFalse(reporter.isRunning()); + } + + @Test + void shutdownClosesWhatWasBuilt() { + AtomicBoolean closed = new AtomicBoolean(); + MetricsReporter reporter = new MetricsReporter(plugin, 1, () -> true, counters -> () -> closed.set(true)); + + reporter.startLater(); + server.getScheduler().performTicks(MetricsReporter.STARTUP_DELAY_TICKS + 5); + reporter.shutdown(); + + assertTrue(closed.get()); + assertFalse(reporter.isRunning()); + } + + @Test + void aFactoryThatThrowsLeavesTheServerAliveAndTheReporterOff() { + MetricsReporter reporter = new MetricsReporter(plugin, 1, () -> true, counters -> { + throw new IllegalStateException("relocation check"); + }); + + reporter.startLater(); + server.getScheduler().performTicks(MetricsReporter.STARTUP_DELAY_TICKS + 5); + + assertFalse(reporter.isRunning()); + assertTrue(plugin.isEnabled()); + } + + @Test + void thePluginsOwnReporterIsOffUnderTest() { + server.getScheduler().performTicks(MetricsReporter.STARTUP_DELAY_TICKS + 5); + assertFalse(plugin.getMetricsReporter().isRunning()); + } + + @Test + void theGlobalBStatsSwitchIsReadFromItsOwnConfig(@TempDir File pluginsDir) throws IOException { + assertTrue(MetricsReporter.bStatsEnabledGlobally(pluginsDir), "no bStats config yet means enabled"); + + File bStatsDir = new File(pluginsDir, "bStats"); + assertTrue(bStatsDir.mkdirs()); + File config = new File(bStatsDir, "config.yml"); + + Files.writeString(config.toPath(), "enabled: false\n"); + assertFalse(MetricsReporter.bStatsEnabledGlobally(pluginsDir)); + + Files.writeString(config.toPath(), "enabled: true\nserverUuid: abc\n"); + assertTrue(MetricsReporter.bStatsEnabledGlobally(pluginsDir)); + } + + @Test + void theTestHarnessDisablesBStatsThroughItsGlobalSwitch() { + assertFalse(MetricsReporter.bStatsEnabledGlobally(plugin.getDataFolder().getParentFile())); + } + + @Test + void commandChartIdsAreStableAndUnderscored() { + assertEquals("command_go_home", MetricsReporter.commandChartId("go-home")); + assertEquals("command_homes", MetricsReporter.commandChartId("homes")); + assertEquals("command_get_player_homes", MetricsReporter.commandChartId("Get-Player-Homes")); + } + + @Test + @SuppressWarnings("deprecation") // Paper deprecates getDescription, but its replacement carries no command list and the reporter itself must stay on the Spigot API. + void everyDeclaredCommandGetsAChartId() { + for (String command : plugin.getDescription().getCommands().keySet()) { + String id = MetricsReporter.commandChartId(command); + assertTrue(id.matches("command_[a-z_]+"), id); + } + assertEquals(14, plugin.getDescription().getCommands().size()); + } + + @Test + void theDeveloperPropertyTurnsReportingOff(@TempDir File pluginsDir) { + // No bStats config in this folder, so only the property can say no. + assertTrue(MetricsReporter.shouldReport(pluginsDir)); + + System.setProperty(MetricsReporter.DISABLE_PROPERTY, "true"); + try { + assertFalse(MetricsReporter.shouldReport(pluginsDir)); + } finally { + System.clearProperty(MetricsReporter.DISABLE_PROPERTY); + } + + assertTrue(MetricsReporter.shouldReport(pluginsDir)); + } + + @Test + void theGlobalSwitchStillWinsWithoutTheProperty(@TempDir File pluginsDir) throws IOException { + File bStatsDir = new File(pluginsDir, "bStats"); + assertTrue(bStatsDir.mkdirs()); + Files.writeString(new File(bStatsDir, "config.yml").toPath(), "enabled: false\n"); + + assertFalse(MetricsReporter.shouldReport(pluginsDir)); + } + + @Test + void aliasChartIdsAreStableAndUnderscored() { + assertEquals("alias_sethome", MetricsReporter.aliasChartId("sethome")); + assertEquals("alias_home_of", MetricsReporter.aliasChartId("home-of")); + assertEquals("alias_get_blacklisted_dimensions", MetricsReporter.aliasChartId("Get-Blacklisted-Dimensions")); + } + + @Test + void everyDeclaredAliasGetsAChartId() { + java.util.List aliases = MetricsReporter.declaredAliases(plugin); + assertEquals(11, aliases.size(), aliases.toString()); + assertTrue(aliases.contains("sethome")); + assertTrue(aliases.contains("get-blacklisted-dimensions")); + for (String alias : aliases) { + assertTrue(MetricsReporter.aliasChartId(alias).matches("alias_[a-z_]+"), alias); + } + } +} diff --git a/src/test/java/com/samleighton/sethomestwo/metrics/UsageCountersTest.java b/src/test/java/com/samleighton/sethomestwo/metrics/UsageCountersTest.java new file mode 100644 index 0000000..aa98a4b --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/metrics/UsageCountersTest.java @@ -0,0 +1,55 @@ +package com.samleighton.sethomestwo.metrics; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static com.samleighton.sethomestwo.metrics.UsageCounters.Family.ALIAS; +import static com.samleighton.sethomestwo.metrics.UsageCounters.Family.COMMAND; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class UsageCountersTest { + + @Test + void incrementsAccumulatePerKey() { + UsageCounters counters = new UsageCounters(); + + counters.increment(COMMAND, "go-home"); + counters.increment(COMMAND, "go-home"); + counters.increment(COMMAND, "create-home"); + + Map snapshot = counters.snapshot(COMMAND); + assertEquals(2, snapshot.get("go-home")); + assertEquals(1, snapshot.get("create-home")); + assertEquals(2, snapshot.size()); + } + + @Test + void snapshotAndResetReturnsTheCountsAndClearsThem() { + UsageCounters counters = new UsageCounters(); + counters.increment(COMMAND, "go-home"); + + Map first = counters.snapshotAndReset(COMMAND); + assertEquals(1, first.get("go-home")); + + assertTrue(counters.snapshotAndReset(COMMAND).isEmpty()); + } + + @Test + void familiesAreIndependent() { + UsageCounters counters = new UsageCounters(); + counters.increment(COMMAND, "go-home"); + counters.increment(ALIAS, "home"); + + counters.snapshotAndReset(COMMAND); + + assertEquals(1, counters.snapshot(ALIAS).get("home")); + assertTrue(counters.snapshot(COMMAND).isEmpty()); + } + + @Test + void anEmptyFamilySnapshotsToAnEmptyMap() { + assertTrue(new UsageCounters().snapshot(COMMAND).isEmpty()); + } +} diff --git a/src/test/java/com/samleighton/sethomestwo/metrics/WindowShareTest.java b/src/test/java/com/samleighton/sethomestwo/metrics/WindowShareTest.java new file mode 100644 index 0000000..e34c775 --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/metrics/WindowShareTest.java @@ -0,0 +1,89 @@ +package com.samleighton.sethomestwo.metrics; + +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import static com.samleighton.sethomestwo.metrics.UsageCounters.Family.COMMAND; +import static com.samleighton.sethomestwo.metrics.UsageCounters.Family.GUI_ACTION; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WindowShareTest { + + private final AtomicLong nanos = new AtomicLong(TimeUnit.HOURS.toNanos(1)); + + private WindowShare share(UsageCounters counters) { + return new WindowShare(counters, nanos::get); + } + + private void later(long seconds) { + nanos.addAndGet(TimeUnit.SECONDS.toNanos(seconds)); + } + + @Test + void everyReaderInOneSubmissionSeesTheSameWindowWhateverTheOrder() { + UsageCounters counters = new UsageCounters(); + counters.increment(COMMAND, "go-home"); + counters.increment(COMMAND, "go-home"); + counters.increment(COMMAND, "create-home"); + WindowShare share = share(counters); + + // A line chart happens to run first: bStats keeps charts in a hash set. + assertEquals(2, share.count(COMMAND, "go-home")); + assertTrue(counters.snapshot(COMMAND).isEmpty(), "the first reader drains the family"); + + Map bars = share.bars(COMMAND); + assertArrayEquals(new int[]{2}, bars.get("go-home")); + assertArrayEquals(new int[]{1}, bars.get("create-home")); + + assertEquals(3, share.total(COMMAND)); + assertEquals(1, share.count(COMMAND, "create-home")); + assertEquals(0, share.count(COMMAND, "delete-home")); + } + + @Test + void theNextSubmissionDrainsAFreshWindow() { + UsageCounters counters = new UsageCounters(); + counters.increment(COMMAND, "go-home"); + WindowShare share = share(counters); + assertEquals(1, share.total(COMMAND)); + + counters.increment(COMMAND, "create-home"); + later(59); + assertEquals(1, share.total(COMMAND), "still the same submission"); + + later(2); + assertEquals(1, share.total(COMMAND), "next submission: only what arrived since"); + assertEquals(1, share.count(COMMAND, "create-home")); + assertEquals(0, share.count(COMMAND, "go-home")); + assertTrue(share.bars(COMMAND).containsKey("create-home")); + } + + @Test + void anEmptyWindowIsStillOneWindow() { + UsageCounters counters = new UsageCounters(); + WindowShare share = share(counters); + + assertTrue(share.bars(COMMAND).isEmpty()); + counters.increment(COMMAND, "go-home"); + assertEquals(0, share.total(COMMAND), "arrived after this submission drained"); + + later(120); + assertEquals(1, share.total(COMMAND)); + } + + @Test + void familiesDoNotLeakIntoEachOther() { + UsageCounters counters = new UsageCounters(); + counters.increment(COMMAND, "go-home"); + WindowShare share = share(counters); + + share.bars(COMMAND); + assertEquals(0, share.total(GUI_ACTION)); + assertEquals(1, share.total(COMMAND)); + } +} diff --git a/src/test/java/com/samleighton/sethomestwo/support/HomeFixtures.java b/src/test/java/com/samleighton/sethomestwo/support/HomeFixtures.java index b004940..62498fb 100644 --- a/src/test/java/com/samleighton/sethomestwo/support/HomeFixtures.java +++ b/src/test/java/com/samleighton/sethomestwo/support/HomeFixtures.java @@ -1,5 +1,6 @@ package com.samleighton.sethomestwo.support; +import com.samleighton.sethomestwo.SetHomesTwo; import com.samleighton.sethomestwo.dao.BlacklistDao; import com.samleighton.sethomestwo.dao.HomesDao; import com.samleighton.sethomestwo.models.Home; @@ -7,6 +8,9 @@ import org.bukkit.Material; import org.bukkit.entity.Player; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; import java.util.Comparator; import java.util.List; import java.util.Objects; @@ -68,10 +72,27 @@ public static Home persist(Player owner, String name) { } /** - * Blacklist a world. ServerUtil maps an environment onto the lowercased - * world name, which is what the blacklist table stores. + * Blacklist a world. The blacklist table stores lowercased world names. */ public static void blacklist(String worldName) { new BlacklistDao().save(worldName.toLowerCase()); } + + /** + * Make every write to the blacklist table fail while leaving reads working, + * which is the state a command has to survive: it has already listed the + * blacklist and only the insert or delete goes wrong. + */ + public static void breakBlacklistWrites() { + Connection connection = SetHomesTwo.instance().getConnectionManager().getConnection("homes"); + + try (Statement statement = connection.createStatement()) { + statement.execute("create trigger no_blacklist_insert before insert on blacklist" + + " begin select raise(abort, 'blacklist writes disabled'); end;"); + statement.execute("create trigger no_blacklist_delete before delete on blacklist" + + " begin select raise(abort, 'blacklist writes disabled'); end;"); + } catch (SQLException e) { + throw new IllegalStateException("Fixture could not disable blacklist writes", e); + } + } } diff --git a/src/test/java/com/samleighton/sethomestwo/support/PluginBoot.java b/src/test/java/com/samleighton/sethomestwo/support/PluginBoot.java new file mode 100644 index 0000000..7936590 --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/support/PluginBoot.java @@ -0,0 +1,37 @@ +package com.samleighton.sethomestwo.support; + +import com.samleighton.sethomestwo.SetHomesTwo; +import org.mockbukkit.mockbukkit.MockBukkit; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +/** + * Loads the plugin for tests that have to arrange state before onEnable runs. + * ServerTestBase cannot do that: it loads the plugin in its own @BeforeEach. + */ +public final class PluginBoot { + + private PluginBoot() { + } + + /** + * Loads the plugin with the same two switches ServerTestBase applies, so a + * drained scheduler can never reach the GitHub API or construct bStats. + */ + public static SetHomesTwo load() { + SetHomesTwo plugin = MockBukkit.load(SetHomesTwo.class); + plugin.getConfig().set("checkForUpdates", false); + + File bStatsDir = new File(plugin.getDataFolder().getParentFile(), "bStats"); + if (!bStatsDir.isDirectory() && !bStatsDir.mkdirs()) + throw new IllegalStateException("could not create " + bStatsDir); + try { + Files.writeString(new File(bStatsDir, "config.yml").toPath(), "enabled: false\n"); + } catch (IOException e) { + throw new IllegalStateException("could not write the bStats opt-out", e); + } + return plugin; + } +} diff --git a/src/test/java/com/samleighton/sethomestwo/support/ServerTestBase.java b/src/test/java/com/samleighton/sethomestwo/support/ServerTestBase.java index c448d25..6c5e558 100644 --- a/src/test/java/com/samleighton/sethomestwo/support/ServerTestBase.java +++ b/src/test/java/com/samleighton/sethomestwo/support/ServerTestBase.java @@ -9,6 +9,9 @@ import org.mockbukkit.mockbukkit.ServerMock; import org.mockbukkit.mockbukkit.world.WorldMock; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; import java.util.UUID; /** @@ -46,6 +49,21 @@ protected void startServer() { // Turned off before anything drains the scheduler, so the check // scheduled during onEnable can never reach the GitHub API. plugin.getConfig().set("checkForUpdates", false); + + // Same reason: the reporter reads bStats' global switch when its delayed + // task fires, so no test can ever construct bStats or make a network request. + disableBStatsGlobally(); + } + + private void disableBStatsGlobally() { + File bStatsDir = new File(plugin.getDataFolder().getParentFile(), "bStats"); + if (!bStatsDir.isDirectory() && !bStatsDir.mkdirs()) + throw new IllegalStateException("could not create " + bStatsDir); + try { + Files.writeString(new File(bStatsDir, "config.yml").toPath(), "enabled: false\n"); + } catch (IOException e) { + throw new IllegalStateException("could not write the bStats opt-out", e); + } } @AfterEach diff --git a/src/test/java/com/samleighton/sethomestwo/tabcompleters/BlacklistTabCompleterTest.java b/src/test/java/com/samleighton/sethomestwo/tabcompleters/BlacklistTabCompleterTest.java new file mode 100644 index 0000000..6d357fe --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/tabcompleters/BlacklistTabCompleterTest.java @@ -0,0 +1,60 @@ +package com.samleighton.sethomestwo.tabcompleters; + +import com.samleighton.sethomestwo.support.HomeFixtures; +import com.samleighton.sethomestwo.support.ServerTestBase; +import org.bukkit.command.PluginCommand; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +import java.util.List; +import java.util.Objects; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class BlacklistTabCompleterTest extends ServerTestBase { + + private List complete(String label, String... args) { + PlayerMock player = addPlayer(); + PluginCommand command = Objects.requireNonNull(plugin.getCommand("blacklist")); + + return new BlacklistTabCompleter().onTabComplete(player, command, label, args); + } + + @Test + void aFragmentFromTheMiddleOfAWorldNameOffersThatWorld() { + assertEquals(List.of("world_nether"), complete("blacklist", "add", "net")); + } + + @Test + void aWorldNamePrefixStillOffersThatWorld() { + assertEquals(List.of("world_nether"), complete("blacklist", "add", "world_n")); + } + + @Test + void theSharedPrefixOffersEveryWorldInServerOrder() { + assertEquals(List.of("world", "world_nether", "world_the_end"), complete("blacklist", "add", "world")); + } + + @Test + void aFragmentMatchingNoWorldOffersNothing() { + assertEquals(List.of(), complete("blacklist", "add", "xyz")); + } + + @Test + void subcommandsMatchAnywhereToo() { + assertEquals(List.of("remove"), complete("blacklist", "mov")); + } + + @Test + void removingOffersOnlyBlacklistedWorldsMatchingTheFragment() { + HomeFixtures.blacklist("world_nether"); + HomeFixtures.blacklist("world_the_end"); + + assertEquals(List.of("world_the_end"), complete("blacklist", "remove", "end")); + } + + @Test + void theOldAliasGoesStraightToWorldNames() { + assertEquals(List.of("world_nether"), complete("add-to-blacklist", "net")); + } +} diff --git a/src/test/java/com/samleighton/sethomestwo/tabcompleters/ImportSourcesTabCompleterTest.java b/src/test/java/com/samleighton/sethomestwo/tabcompleters/ImportSourcesTabCompleterTest.java new file mode 100644 index 0000000..98a5031 --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/tabcompleters/ImportSourcesTabCompleterTest.java @@ -0,0 +1,42 @@ +package com.samleighton.sethomestwo.tabcompleters; + +import com.samleighton.sethomestwo.support.ServerTestBase; +import org.bukkit.command.PluginCommand; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +import java.util.List; +import java.util.Objects; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ImportSourcesTabCompleterTest extends ServerTestBase { + + private List complete(String... args) { + PlayerMock player = addPlayer(); + PluginCommand command = Objects.requireNonNull(plugin.getCommand("import-homes")); + + return new ImportSourcesTabCompleter().onTabComplete(player, command, "import-homes", args); + } + + @Test + void theFirstArgumentOffersTheImportSources() { + List completions = complete(""); + + assertTrue(completions.contains("sethomes"), completions.toString()); + assertTrue(completions.contains("essentialsx"), completions.toString()); + } + + @Test + void theSecondArgumentOffersConfirm() { + assertEquals(List.of("confirm"), complete("sethomes", "")); + } + + @Test + void beyondTheKnownArgumentsNothingIsOffered() { + // Empty, not null. Bukkit falls back to suggesting online player names + // when a completer returns null, which is meaningless for this command. + assertEquals(List.of(), complete("sethomes", "confirm", "")); + } +} diff --git a/src/test/java/com/samleighton/sethomestwo/utils/BlacklistEnforcementTest.java b/src/test/java/com/samleighton/sethomestwo/utils/BlacklistEnforcementTest.java new file mode 100644 index 0000000..db65e45 --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/utils/BlacklistEnforcementTest.java @@ -0,0 +1,73 @@ +package com.samleighton.sethomestwo.utils; + +import com.samleighton.sethomestwo.support.HomeFixtures; +import com.samleighton.sethomestwo.support.ServerTestBase; +import org.bukkit.Location; +import org.bukkit.World; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockbukkit.mockbukkit.world.WorldMock; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BlacklistEnforcementTest extends ServerTestBase { + + @Test + void aConventionalWorldStillEnforces() { + HomeFixtures.blacklist("world_nether"); + + assertTrue(ServerUtil.isWorldBlacklisted(nether)); + assertFalse(ServerUtil.isWorldBlacklisted(overworld)); + } + + @Test + void aFourthWorldEnforces() { + WorldMock creative = server.addSimpleWorld("creative"); + creative.setEnvironment(World.Environment.NORMAL); + HomeFixtures.blacklist("creative"); + + // The positional map could never address a fourth world, so this is the + // regression test for the silent no-op. + assertTrue(ServerUtil.isWorldBlacklisted(creative)); + assertFalse(ServerUtil.isWorldBlacklisted(overworld)); + } + + @Test + void aSecondOverworldIsBlacklistedIndependently() { + WorldMock resource = server.addSimpleWorld("resource"); + resource.setEnvironment(World.Environment.NORMAL); + HomeFixtures.blacklist("resource"); + + assertTrue(ServerUtil.isWorldBlacklisted(resource)); + assertFalse(ServerUtil.isWorldBlacklisted(overworld)); + } + + @Test + void creatingAHomeInABlacklistedFourthWorldIsRefused() { + WorldMock creative = server.addSimpleWorld("creative"); + creative.setEnvironment(World.Environment.NORMAL); + HomeFixtures.blacklist("creative"); + + PlayerMock player = addPlayer(); + player.teleport(new Location(creative, 0, 64, 0)); + + assertTrue(server.execute("create-home", player, "base").hasSucceeded()); + + assertTrue(player.nextMessage().contains("blacklisted")); + assertTrue(new com.samleighton.sethomestwo.dao.HomesDao().getAll(player.getUniqueId()).isEmpty()); + } + + @Test + void aHomeInABlacklistedFourthWorldCannotBeTeleportedTo() { + WorldMock creative = server.addSimpleWorld("creative"); + creative.setEnvironment(World.Environment.NORMAL); + + PlayerMock player = addPlayer(); + HomeFixtures.persist(HomeFixtures.home(player, "far", new Location(creative, 0, 64, 0))); + HomeFixtures.blacklist("creative"); + + assertFalse(new com.samleighton.sethomestwo.dao.HomesDao() + .getAll(player.getUniqueId()).get(0).getCanTeleport()); + } +} diff --git a/src/test/java/com/samleighton/sethomestwo/utils/BypassNodesTest.java b/src/test/java/com/samleighton/sethomestwo/utils/BypassNodesTest.java new file mode 100644 index 0000000..a30fbc0 --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/utils/BypassNodesTest.java @@ -0,0 +1,239 @@ +package com.samleighton.sethomestwo.utils; + +import com.samleighton.sethomestwo.dao.Dao; +import com.samleighton.sethomestwo.dao.HomesDao; +import com.samleighton.sethomestwo.dao.TeleportAttemptsDao; +import com.samleighton.sethomestwo.models.Home; +import com.samleighton.sethomestwo.models.TeleportAttempt; +import com.samleighton.sethomestwo.support.HomeFixtures; +import com.samleighton.sethomestwo.support.ServerTestBase; +import com.samleighton.sethomestwo.support.TestPlayer; +import org.bukkit.Location; +import org.bukkit.permissions.Permission; +import org.bukkit.permissions.PermissionDefault; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The three bypass nodes, each proven with a holder and a non-holder so the node + * is what makes the difference rather than the surrounding config. + */ +class BypassNodesTest extends ServerTestBase { + + private static final List NODES = List.of( + "sh2.bypass-max-homes", + "sh2.bypass-blacklist", + "sh2.bypass-teleport-delay" + ); + + @Test + void aFixturePlayerHoldsNoneOfTheNodes() { + // All three default to op. An opped fixture would switch off the rules the + // blacklist and max-homes suites pin, so those tests would pass for the + // wrong reason. + TestPlayer player = addPlayer(); + + assertFalse(player.isOp(), "a fixture player must not be an operator"); + NODES.forEach(node -> assertFalse(player.hasPermission(node), node)); + } + + @Test + void theNodesAreGrantedOnlyByTheAdminBundle() { + Permission adminBundle = server.getPluginManager().getPermission("sh2.admin"); + assertNotNull(adminBundle); + + for (String node : NODES) { + Permission permission = server.getPluginManager().getPermission(node); + assertNotNull(permission, node); + assertEquals(PermissionDefault.FALSE, permission.getDefault(), node); + assertTrue(adminBundle.getChildren().containsKey(node), node); + } + } + + @Test + void aHolderOfTheMaxHomesNodeExceedsTheLimit() { + limitToOneHome(); + TestPlayer player = addPlayer(); + player.addAttachment(plugin, "sh2.bypass-max-homes", true); + HomeFixtures.persist(player, "base"); + + assertTrue(server.execute("create-home", player, "second").hasSucceeded()); + + assertEquals(2, new HomesDao().getAll(player.getUniqueId()).size()); + } + + @Test + void withoutTheMaxHomesNodeTheLimitApplies() { + limitToOneHome(); + TestPlayer player = addPlayer(); + HomeFixtures.persist(player, "base"); + + assertTrue(server.execute("create-home", player, "second").hasSucceeded()); + + assertEquals(1, new HomesDao().getAll(player.getUniqueId()).size()); + } + + @Test + void aHolderOfTheBlacklistNodeCreatesAHomeInABlacklistedWorld() { + TestPlayer player = addPlayer(); + player.addAttachment(plugin, "sh2.bypass-blacklist", true); + HomeFixtures.blacklist(nether.getName()); + player.teleport(new Location(nether, 10, 70, 10)); + + assertTrue(server.execute("create-home", player, "base").hasSucceeded()); + + assertEquals(1, new HomesDao().getAll(player.getUniqueId()).size()); + } + + @Test + void withoutTheBlacklistNodeCreatingInABlacklistedWorldIsRefused() { + TestPlayer player = addPlayer(); + HomeFixtures.blacklist(nether.getName()); + player.teleport(new Location(nether, 10, 70, 10)); + + assertTrue(server.execute("create-home", player, "base").hasSucceeded()); + + assertTrue(player.nextMessage().contains("blacklisted")); + assertTrue(new HomesDao().getAll(player.getUniqueId()).isEmpty()); + } + + @Test + void aHolderOfTheBlacklistNodeMovesAHomeIntoABlacklistedWorld() { + TestPlayer player = addPlayer(); + player.addAttachment(plugin, "sh2.bypass-blacklist", true); + HomeFixtures.persist(player, "base"); + HomeFixtures.blacklist(nether.getName()); + player.teleport(new Location(nether, 10, 70, 10)); + + assertTrue(server.execute("move-home", player, "base").hasSucceeded()); + + Home moved = new HomesDao(true).getAll(player.getUniqueId()).get(0); + assertEquals(nether.getUID().toString(), moved.getWorld()); + assertEquals(10.0, moved.getX()); + } + + @Test + void withoutTheBlacklistNodeMovingIntoABlacklistedWorldIsRefused() { + TestPlayer player = addPlayer(); + HomeFixtures.persist(player, "base"); + HomeFixtures.blacklist(nether.getName()); + player.teleport(new Location(nether, 10, 70, 10)); + + assertTrue(server.execute("move-home", player, "base").hasSucceeded()); + + assertTrue(player.nextMessage().contains("blacklisted")); + assertEquals(overworld.getUID().toString(), + new HomesDao(true).getAll(player.getUniqueId()).get(0).getWorld()); + } + + @Test + void aHolderOfTheBlacklistNodeTeleportsToAHomeInABlacklistedWorld() { + disableTeleportSafety(); + plugin.getConfig().set("delay", 0); + TestPlayer player = addPlayer(); + player.addAttachment(plugin, "sh2.bypass-blacklist", true); + player.teleport(new Location(overworld, 0, 64, 0)); + HomeFixtures.persist(HomeFixtures.home(player, "far", new Location(nether, 100, 70, 100))); + HomeFixtures.blacklist(nether.getName()); + + assertTrue(server.execute("go-home", player, "far").hasSucceeded()); + server.getScheduler().performTicks(100L); + + assertEquals(nether.getName(), player.getWorld().getName()); + assertEquals(100, player.getLocation().getBlockX()); + } + + @Test + void withoutTheBlacklistNodeTheHomeStaysUnreachable() { + disableTeleportSafety(); + plugin.getConfig().set("delay", 0); + TestPlayer player = addPlayer(); + player.teleport(new Location(overworld, 0, 64, 0)); + HomeFixtures.persist(HomeFixtures.home(player, "far", new Location(nether, 100, 70, 100))); + HomeFixtures.blacklist(nether.getName()); + + assertTrue(server.execute("go-home", player, "far").hasSucceeded()); + server.getScheduler().performTicks(100L); + + assertEquals(overworld.getName(), player.getWorld().getName()); + assertEquals(0, player.getLocation().getBlockX()); + } + + @Test + void aHolderOfTheDelayNodeArrivesWithoutWaitingOutTheCountdown() { + disableTeleportSafety(); + plugin.getConfig().set("delay", 3); + TestPlayer player = addPlayer(); + player.addAttachment(plugin, "sh2.bypass-teleport-delay", true); + player.teleport(new Location(overworld, 0, 64, 0)); + HomeFixtures.persist(HomeFixtures.home(player, "base", new Location(overworld, 100, 70, 100))); + + assertTrue(server.execute("go-home", player, "base").hasSucceeded()); + server.getScheduler().performOneTick(); + + assertEquals(100, player.getLocation().getBlockX()); + } + + @Test + void withoutTheDelayNodeTheCountdownStillRuns() { + disableTeleportSafety(); + plugin.getConfig().set("delay", 3); + TestPlayer player = addPlayer(); + player.teleport(new Location(overworld, 0, 64, 0)); + HomeFixtures.persist(HomeFixtures.home(player, "base", new Location(overworld, 100, 70, 100))); + + assertTrue(server.execute("go-home", player, "base").hasSucceeded()); + server.getScheduler().performOneTick(); + + assertEquals(0, player.getLocation().getBlockX()); + } + + @Test + void aHolderOfTheDelayNodeIsNotCancelledByMoving() { + TestPlayer player = addPlayer(); + player.addAttachment(plugin, "sh2.bypass-teleport-delay", true); + player.teleport(new Location(overworld, 0, 64, 0)); + + Dao attempts = new TeleportAttemptsDao(); + attempts.save(new TeleportAttempt(player, player.getLocation())); + player.teleport(new Location(overworld, 20, 64, 20)); + + TeleportAttempt attempt = attempts.get(player); + assertNotNull(attempt); + assertTrue(attempt.canTeleport()); + } + + @Test + void withoutTheDelayNodeMovingCancelsTheAttempt() { + TestPlayer player = addPlayer(); + player.teleport(new Location(overworld, 0, 64, 0)); + + Dao attempts = new TeleportAttemptsDao(); + attempts.save(new TeleportAttempt(player, player.getLocation())); + player.teleport(new Location(overworld, 20, 64, 20)); + + TeleportAttempt attempt = attempts.get(player); + assertNotNull(attempt); + assertFalse(attempt.canTeleport()); + } + + private void limitToOneHome() { + plugin.getConfig().set("maxHomeEnabled", true); + plugin.getConfig().set("maxHomesType", "singular"); + plugin.getConfig().set("maxHomes", 1); + } + + /** + * TeleportSafetyUtil.prefetchChunks calls WorldMock.addPluginChunkTicket, which + * MockBukkit 4.110.0 does not implement. + */ + private void disableTeleportSafety() { + plugin.getConfig().set("teleportSafety", false); + } +} diff --git a/src/test/java/com/samleighton/sethomestwo/utils/PermissionBundlesTest.java b/src/test/java/com/samleighton/sethomestwo/utils/PermissionBundlesTest.java new file mode 100644 index 0000000..0dc3bb7 --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/utils/PermissionBundlesTest.java @@ -0,0 +1,87 @@ +package com.samleighton.sethomestwo.utils; + +import com.samleighton.sethomestwo.support.ServerTestBase; +import org.bukkit.permissions.Permission; +import org.bukkit.permissions.PermissionDefault; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PermissionBundlesTest extends ServerTestBase { + + @Test + void theAdminBundleConfersItsChildren() { + Permission adminBundle = server.getPluginManager().getPermission("sh2.admin"); + assertNotNull(adminBundle); + assertTrue(adminBundle.getChildren().containsKey("sh2.player")); + assertTrue(adminBundle.getChildren().containsKey("sh2.get-player-homes")); + assertTrue(adminBundle.getChildren().containsKey("sh2.set-max-homes")); + assertTrue(adminBundle.getChildren().containsKey("sh2.import-homes")); + + var player = addPlayer(); + player.addAttachment(plugin, "sh2.admin", true); + + assertTrue(player.hasPermission("sh2.get-player-homes")); + assertTrue(player.hasPermission("sh2.set-max-homes")); + assertTrue(player.hasPermission("sh2.import-homes")); + } + + @Test + void thePlayerBundleConfersItsChildren() { + Permission playerBundle = server.getPluginManager().getPermission("sh2.player"); + assertNotNull(playerBundle); + assertTrue(playerBundle.getChildren().containsKey("sh2.create-home")); + assertTrue(playerBundle.getChildren().containsKey("sh2.go-home")); + assertTrue(playerBundle.getChildren().containsKey("sh2.manage-homes")); + + var player = addPlayer(); + player.addAttachment(plugin, "sh2.player", true); + + assertTrue(player.hasPermission("sh2.create-home")); + assertTrue(player.hasPermission("sh2.go-home")); + assertTrue(player.hasPermission("sh2.manage-homes")); + } + + @Test + void theBundleDefaultsMatchTheirMembers() { + assertEquals(PermissionDefault.TRUE, + server.getPluginManager().getPermission("sh2.player").getDefault()); + assertEquals(PermissionDefault.OP, + server.getPluginManager().getPermission("sh2.admin").getDefault()); + } + + @Test + void stockDefaultsGiveOrdinaryPlayersTheirNodesAndNothingElse() { + PlayerMock player = addPlayer(); + + for (String node : new String[]{"sh2.create-home", "sh2.go-home", "sh2.list-homes", + "sh2.delete-home", "sh2.teleport", "sh2.give-homes-item", "sh2.manage-homes", + "sh2.move-home"}) { + assertTrue(player.hasPermission(node), node); + } + + for (String node : new String[]{"sh2.import-homes", "sh2.get-player-homes", + "sh2.set-max-homes", "sh2.add-to-blacklist", "sh2.remove-from-blacklist", + "sh2.get-blacklisted-dimensions", "sh2.go-player-home", "sh2.delete-player-home", + "sh2.move-player-home", "sh2.bypass-max-homes", "sh2.bypass-blacklist", + "sh2.bypass-teleport-delay", "sh2.update-notify"}) { + assertFalse(player.hasPermission(node), node); + } + } + + @Test + void stockDefaultsGiveOperatorsEverything() { + PlayerMock op = addPlayer(); + op.setOp(true); + + for (String node : new String[]{"sh2.create-home", "sh2.manage-homes", "sh2.import-homes", + "sh2.get-player-homes", "sh2.delete-player-home", "sh2.bypass-blacklist", + "sh2.bypass-max-homes", "sh2.bypass-teleport-delay"}) { + assertTrue(op.hasPermission(node), node); + } + } +} diff --git a/src/test/java/com/samleighton/sethomestwo/utils/PermissionOverridesTest.java b/src/test/java/com/samleighton/sethomestwo/utils/PermissionOverridesTest.java new file mode 100644 index 0000000..0f42028 --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/utils/PermissionOverridesTest.java @@ -0,0 +1,256 @@ +package com.samleighton.sethomestwo.utils; + +import com.samleighton.sethomestwo.support.ServerTestBase; +import org.bukkit.Bukkit; +import org.bukkit.permissions.PermissionDefault; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PermissionOverridesTest extends ServerTestBase { + + @Test + void aConfiguredDefaultIsApplied() { + plugin.getConfig().set("permissions.sh2.import-homes", "true"); + + PermissionOverrides.apply(); + + assertEquals(PermissionDefault.TRUE, + server.getPluginManager().getPermission("sh2.import-homes").getDefault()); + } + + @Test + void notOpIsAccepted() { + plugin.getConfig().set("permissions.sh2.manage-homes", "not-op"); + + PermissionOverrides.apply(); + + assertEquals(PermissionDefault.NOT_OP, + server.getPluginManager().getPermission("sh2.manage-homes").getDefault()); + } + + @Test + void anUnknownNodeIsIgnored() { + plugin.getConfig().set("permissions.sh2.not-a-real-node", "true"); + + List logged = captureLog(PermissionOverrides::apply); + + assertEquals(null, server.getPluginManager().getPermission("sh2.not-a-real-node")); + assertTrue(loggedWarning(logged, + "SetHomesTwo: ignoring unknown permission node 'sh2.not-a-real-node' in config.yml."), + "Expected a warning naming the unknown node"); + } + + @Test + void anUnparseableValueLeavesTheDefaultAlone() { + plugin.getConfig().set("permissions.sh2.import-homes", "sometimes"); + + List logged = captureLog(PermissionOverrides::apply); + + assertEquals(PermissionDefault.FALSE, + server.getPluginManager().getPermission("sh2.import-homes").getDefault()); + assertTrue(opped().hasPermission("sh2.import-homes")); + assertTrue(loggedWarning(logged, + "SetHomesTwo: ignoring permission 'sh2.import-homes', value 'sometimes' is not one of true, false, op, not-op."), + "Expected a warning naming the node and the rejected value"); + } + + @Test + void aWildcardIsNotHonoured() { + plugin.getConfig().set("permissions.sh2.*", "true"); + + PermissionOverrides.apply(); + + assertEquals(PermissionDefault.FALSE, + server.getPluginManager().getPermission("sh2.import-homes").getDefault()); + assertTrue(opped().hasPermission("sh2.import-homes")); + } + + @Test + void noPermissionsSectionLeavesStockDefaults() { + PermissionOverrides.apply(); + + assertEquals(PermissionDefault.FALSE, + server.getPluginManager().getPermission("sh2.import-homes").getDefault()); + assertTrue(opped().hasPermission("sh2.import-homes")); + // The player nodes declare false and are granted by the sh2.player + // bundle, so denying the bundle takes all eight away at once. + assertEquals(PermissionDefault.FALSE, + server.getPluginManager().getPermission("sh2.create-home").getDefault()); + assertTrue(addPlayer().hasPermission("sh2.create-home")); + } + + @Test + void anExplicitGrantStillBeatsAConfiguredDefault() { + plugin.getConfig().set("permissions.sh2.import-homes", "false"); + PermissionOverrides.apply(); + + var player = addPlayer(); + player.addAttachment(plugin, "sh2.import-homes", true); + + org.junit.jupiter.api.Assertions.assertTrue(player.hasPermission("sh2.import-homes")); + } + + /** + * Captures what gets logged during {@code action}. The handler is always + * removed afterward so it cannot leak into other tests. + */ + private List captureLog(Runnable action) { + List captured = new ArrayList<>(); + Handler handler = new Handler() { + @Override + public void publish(LogRecord record) { + captured.add(record); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }; + + Logger logger = Bukkit.getLogger(); + logger.addHandler(handler); + try { + action.run(); + } finally { + logger.removeHandler(handler); + } + + return captured; + } + + private boolean loggedWarning(List records, String message) { + return records.stream().anyMatch( + record -> record.getLevel() == Level.WARNING && message.equals(record.getMessage())); + } + + @Test + void aDeniedNodeIsDeniedDespiteItsBundle() { + plugin.getConfig().set("permissions.sh2.manage-homes", false); + + PermissionOverrides.apply(); + + // sh2.player is default true and lists sh2.manage-homes as a child, so + // Bukkit writes the child straight into every player's effective map. + // Lowering the node's own default is not enough on its own. + assertFalse(addPlayer().hasPermission("sh2.manage-homes")); + } + + @Test + void aDeniedAdminNodeIsDeniedForOperators() { + plugin.getConfig().set("permissions.sh2.import-homes", false); + + PermissionOverrides.apply(); + + PlayerMock op = addPlayer(); + op.setOp(true); + + assertFalse(op.hasPermission("sh2.import-homes")); + } + + @Test + void aNodeRaisedToOpNoLongerReachesOrdinaryPlayers() { + plugin.getConfig().set("permissions.sh2.manage-homes", "op"); + + PermissionOverrides.apply(); + + PlayerMock player = addPlayer(); + PlayerMock op = addPlayer("Op"); + op.setOp(true); + + assertFalse(player.hasPermission("sh2.manage-homes")); + assertTrue(op.hasPermission("sh2.manage-homes")); + } + + @Test + void aGrantedNodeStillReachesPlayersThroughItsBundle() { + plugin.getConfig().set("permissions.sh2.get-player-homes", true); + + PermissionOverrides.apply(); + + assertTrue(addPlayer().hasPermission("sh2.get-player-homes")); + // Untouched nodes must keep working. + assertTrue(addPlayer("Other").hasPermission("sh2.create-home")); + } + + @Test + void denyingTheBundleTakesEveryPlayerNodeAtOnce() { + plugin.getConfig().set("permissions.sh2.player", false); + + PermissionOverrides.apply(); + + PlayerMock player = addPlayer(); + assertFalse(player.hasPermission("sh2.player")); + assertFalse(player.hasPermission("sh2.create-home")); + assertFalse(player.hasPermission("sh2.go-home")); + assertFalse(player.hasPermission("sh2.manage-homes")); + } + + @Test + void denyingThePlayerBundleTakesItFromOperatorsToo() { + plugin.getConfig().set("permissions.sh2.player", false); + + PermissionOverrides.apply(); + + PlayerMock op = addPlayer(); + op.setOp(true); + + // sh2.admin lists sh2.player as a child, so a deny that stopped at + // ordinary players would leave operators holding the whole set. + assertFalse(op.hasPermission("sh2.player")); + assertFalse(op.hasPermission("sh2.create-home")); + assertTrue(op.hasPermission("sh2.import-homes"), "the admin-only nodes are untouched"); + } + + @Test + void aMixedCaseNodeStillDetachesFromItsBundle() { + plugin.getConfig().set("permissions.SH2.Manage-Homes", false); + + PermissionOverrides.apply(); + + // getPermission lowercases its lookup but a bundle's children map does + // not, so a node found by one and missed by the other reads as applied + // in the log while sh2.player carries on granting it. + assertFalse(addPlayer().hasPermission("sh2.manage-homes")); + } + + @Test + void denyingTheAdminBundleLeavesOrdinaryPlayersAlone() { + plugin.getConfig().set("permissions.sh2.admin", false); + + PermissionOverrides.apply(); + + PlayerMock op = addPlayer(); + op.setOp(true); + + assertFalse(op.hasPermission("sh2.import-homes")); + assertTrue(addPlayer("Plain").hasPermission("sh2.create-home")); + } + + /** + * The admin nodes declare false and are granted by the sh2.admin bundle, + * which is op by default, so effective access is what tells you whether a + * node is still operator only. + */ + private PlayerMock opped() { + PlayerMock op = addPlayer("Op" + (opCount++)); + op.setOp(true); + return op; + } + + private int opCount = 0; +} diff --git a/src/test/java/com/samleighton/sethomestwo/utils/ServerUtilOfflineLookupTest.java b/src/test/java/com/samleighton/sethomestwo/utils/ServerUtilOfflineLookupTest.java new file mode 100644 index 0000000..02dc394 --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/utils/ServerUtilOfflineLookupTest.java @@ -0,0 +1,53 @@ +package com.samleighton.sethomestwo.utils; + +import com.samleighton.sethomestwo.support.HomeFixtures; +import com.samleighton.sethomestwo.support.ServerTestBase; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class ServerUtilOfflineLookupTest extends ServerTestBase { + + @Test + void anOnlinePlayerStillResolves() { + PlayerMock player = addPlayer("Steve"); + + assertEquals(player.getUniqueId().toString(), ServerUtil.getPlayerUUID("Steve")); + } + + @Test + void anOfflinePlayerWithStoredHomesResolves() { + PlayerMock player = addPlayer("Steve"); + HomeFixtures.persist(player, "base"); + String expected = player.getUniqueId().toString(); + + player.disconnect(); + + assertEquals(expected, ServerUtil.getPlayerUUID("Steve")); + } + + @Test + void anOnlinePlayerResolvesRegardlessOfCase() { + PlayerMock player = addPlayer("Steve"); + + assertEquals(player.getUniqueId().toString(), ServerUtil.getPlayerUUID("steve")); + } + + @Test + void anOfflinePlayerResolvesRegardlessOfCase() { + PlayerMock player = addPlayer("Steve"); + HomeFixtures.persist(player, "base"); + String expected = player.getUniqueId().toString(); + + player.disconnect(); + + assertEquals(expected, ServerUtil.getPlayerUUID("sTeVe")); + } + + @Test + void aPlayerWithNoHomesAndNoSessionDoesNotResolve() { + assertNull(ServerUtil.getPlayerUUID("Nobody")); + } +} diff --git a/src/test/java/com/samleighton/sethomestwo/utils/TabCompletionsTest.java b/src/test/java/com/samleighton/sethomestwo/utils/TabCompletionsTest.java new file mode 100644 index 0000000..8fb78fc --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/utils/TabCompletionsTest.java @@ -0,0 +1,57 @@ +package com.samleighton.sethomestwo.utils; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class TabCompletionsTest { + + private static final List WORLDS = List.of("world", "world_nether", "world_the_end"); + + @Test + void anEmptyFragmentOffersEverythingInSourceOrder() { + assertEquals(WORLDS, TabCompletions.matching("", WORLDS)); + } + + @Test + void aFragmentFromTheMiddleOfANameMatches() { + assertEquals(List.of("world_nether"), TabCompletions.matching("net", WORLDS)); + } + + @Test + void aPrefixStillMatches() { + assertEquals(List.of("world_nether"), TabCompletions.matching("world_n", WORLDS)); + } + + @Test + void matchingIgnoresCase() { + assertEquals(List.of("world_nether"), TabCompletions.matching("NET", WORLDS)); + // The candidate keeps its own casing in the result. + assertEquals(List.of("World_Nether"), TabCompletions.matching("net", List.of("World_Nether"))); + } + + @Test + void prefixMatchesRankAboveSubstringMatches() { + // "minecart" contains "cart" and comes first in source order, but + // "cart" starts with it and must be offered first. + assertEquals(List.of("cart", "minecart"), TabCompletions.matching("cart", List.of("minecart", "cart"))); + } + + @Test + void sourceOrderIsKeptWithinEachTier() { + List source = List.of("stone_axe", "axe", "sandstone", "stone"); + assertEquals(List.of("stone_axe", "stone", "sandstone"), TabCompletions.matching("stone", source)); + } + + @Test + void aNameIsOfferedOnce() { + assertEquals(List.of("stone"), TabCompletions.matching("stone", List.of("stone", "stone"))); + } + + @Test + void aFragmentThatMatchesNothingOffersNothing() { + assertEquals(List.of(), TabCompletions.matching("xyz", WORLDS)); + } +}