Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4abddb5d4d | ||
|
|
4d90add014 | ||
|
|
bf7f01a39e | ||
|
|
3bd2dc7d14 | ||
|
|
de8ff83c3c | ||
|
|
02f74d1822 | ||
|
|
e12e31c0ad | ||
|
|
07061a3622 | ||
|
|
72e58383a2 | ||
|
|
bb537b0556 | ||
|
|
2624c2bc02 | ||
|
|
719cd49609 | ||
|
|
be13b9b87c | ||
|
|
af6d6d0827 | ||
|
|
5ca939be6c | ||
|
|
9344390599 |
No files matched your search
@@ -35,6 +35,6 @@ jobs:
|
||||
- name: Upload development artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: spigot-quest-board-${{ github.sha }}
|
||||
name: purpur-quest-board-${{ github.sha }}
|
||||
path: build/libs/*.jar
|
||||
if-no-files-found: error
|
||||
@@ -86,8 +86,8 @@ jobs:
|
||||
if: steps.release.outputs.created == 'true'
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: spigot-quest-board-${{ steps.release.outputs.version }}
|
||||
path: build/libs/spigot-quest-board-${{ steps.release.outputs.version }}.jar
|
||||
name: purpur-quest-board-${{ steps.release.outputs.version }}
|
||||
path: build/libs/purpur-quest-board-${{ steps.release.outputs.version }}.jar
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create Gitea release and upload plugin
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
PREVIOUS_TAG: ${{ steps.previous_tag.outputs.value }}
|
||||
run: |
|
||||
api_url="${GITEA_SERVER_URL}/api/v1"
|
||||
jar="build/libs/spigot-quest-board-${VERSION}.jar"
|
||||
jar="build/libs/purpur-quest-board-${VERSION}.jar"
|
||||
export RELEASE_BODY
|
||||
if [ -n "$PREVIOUS_TAG" ]; then
|
||||
RELEASE_BODY=$(git log --pretty='format:- %s (%h)' "${PREVIOUS_TAG}..HEAD")
|
||||
@@ -138,4 +138,4 @@ jobs:
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${jar}" \
|
||||
"${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=spigot-quest-board-${VERSION}.jar"
|
||||
"${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=purpur-quest-board-${VERSION}.jar"
|
||||
@@ -1,5 +1,6 @@
|
||||
.gradle/
|
||||
build/
|
||||
.local-server/
|
||||
out/
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
@@ -1,40 +1,9 @@
|
||||
# Spigot Quest Board Agent Guide
|
||||
# purpur-quest-board agent entrypoint
|
||||
|
||||
## Project
|
||||
The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
|
||||
|
||||
Spigot Quest Board is a Java 25 plugin for Purpur 26.2. It provides shared physical quest boards where players escrow rewards, deliver requested block items, and claim delivered or returned items.
|
||||
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-quest-board/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/purpur-quest-board/` stories. Also follow the parent workspace `AGENTS.md` when present.
|
||||
|
||||
## Canonical knowledge
|
||||
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-quest-board/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
|
||||
|
||||
- `knowledge/` is the canonical OKF v0.1 knowledge bundle.
|
||||
- Read `knowledge/index.md` and the relevant documents in `knowledge/user-stories/` before changing behavior.
|
||||
- Every feature or behavior change requires an approved user story with task-list acceptance criteria.
|
||||
- Preserve unknown YAML frontmatter fields when editing knowledge documents.
|
||||
- Keep `knowledge/index.md`, `knowledge/log.md`, and `knowledge/user-stories/index.md` current.
|
||||
|
||||
## Development
|
||||
|
||||
- Compile against the Purpur API version declared in `build.gradle.kts`.
|
||||
- Use Java 25 and enable strict compiler linting.
|
||||
- Prefer small, server-independent domain objects and services that can be unit tested.
|
||||
- Use test-driven development where practical: add a failing test, implement the minimum behavior, then refactor.
|
||||
- Treat escrow, completion, cancellation, expiry, and claiming as atomic state transitions. Never duplicate or silently discard player items.
|
||||
- Key players by UUID, not display name.
|
||||
- Persist board, quest, escrow, and claim state safely across restarts using atomic file replacement where supported.
|
||||
- Use UTC and RFC 3339 timestamps for persisted or documented date-times.
|
||||
- Keep player-facing command autocomplete permission-aware and context-sensitive.
|
||||
- Use Purpur's supported Paper dialog API for native dialog interfaces.
|
||||
|
||||
## Verification
|
||||
|
||||
Run before completing a story:
|
||||
|
||||
```bash
|
||||
./gradlew clean check jar
|
||||
```
|
||||
|
||||
Mark acceptance criteria complete only after implementation and verification.
|
||||
|
||||
## Git
|
||||
|
||||
Use conventional commits in the form `type(scope): description`, with summaries under 72 characters.
|
||||
Development follows [Development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
|
||||
@@ -1,12 +1,38 @@
|
||||
# Spigot Quest Board
|
||||
# Purpur Quest Board
|
||||
|
||||
A Purpur 26.2 plugin for shared physical quest boards. Players can escrow rewards for requested block items, deliver those blocks at any quest board, and claim delivered or returned items safely.
|
||||
A Purpur 26.2 plugin for shared physical quest boards. Players can escrow rewards for item deliveries or player bounties, fulfill them, and collect exact rewards through shared boards.
|
||||
|
||||
The approved behavior is specified in the [OKF knowledge bundle](knowledge/index.md).
|
||||
The approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-quest-board/index.md).
|
||||
|
||||
## Status
|
||||
|
||||
Administrators can register persistent shared quest boards by targeting a block within five blocks and running `/questadmin createboard`. Right-clicking any registered board opens a native dialog where a player can request a block and quantity while escrowing the exact reward stack held in their main hand. Every board and `/quests list` show the same active quests with requested blocks, rewards, issuers, and time remaining. Delivery and claims remain under development.
|
||||
Administrators can register persistent shared quest boards by targeting a block within five blocks and running `/questadmin createboard`. They can instead run `/questadmin createboard physical` to generate a five-wide oak board above the targeted ground anchor, with glowing centered title and browsing-instruction signs above three decorative signs; its visible panel blocks and all five signs are registered. Right-clicking any registered board opens a compact native dashboard with dedicated browsing, creation, and claim screens. A player can request any inventory item material and quantity while escrowing the exact reward stack held in their main hand. Every board and `/quests list` show the same active quests with requested items, rewards, issuers, and time remaining; custom reward names are shown together with their material type. Players can complete quests at any board or with `/quests complete <quest>` by delivering the required items. After durable completion, exact escrowed rewards are delivered to the hunter (or retained for collection if delivery cannot proceed), and delivered items are held for the issuer. Issuers can cancel their own active quests at any board or with `/quests cancel <quest>`. Completed deliveries and rewards from cancelled or seven-day-expired quests are held durably and can be collected at any board or with `/quests claim`; inventory overflow drops at the claimant's feet.
|
||||
|
||||
## Player bounties
|
||||
|
||||
At any registered board, choose **Create bounty**, enter a player name or UUID and a positive kill count, and hold the exact reward stack until target resolution finishes. Online, cached and asynchronously resolved offline targets are supported. Boards work even with player commands disabled; posting bounties uses the board, not a new command.
|
||||
|
||||
- Each hunter has separate UUID-based progress. The first to reach the full count earns the whole reward; the issuer cannot win.
|
||||
- Only actual, non-cancelled deaths attributed by the server to a player count, including credited projectiles. Bounties never enable PvP or bypass protections.
|
||||
- Browse shows target identity, your progress, exact named reward and remaining time. Earned bounty rewards appear separately in **Pending claims**.
|
||||
- Issuers may cancel unfinished bounties, even after partial progress. Cancellation or seven-day expiry returns unpaid escrow; no partial payouts.
|
||||
- There is no added kill cooldown, anti-collusion rule or target-consent gate. Existing server conduct rules remain authoritative.
|
||||
|
||||
## Persistence and recovery
|
||||
|
||||
Quest/bounty/claim persistence runs on a serialized worker; Bukkit inventory and dialog operations run on the server thread. Existing delivery quests and claims are preserved. Native-callback, death-attribution, restart, failure and lifecycle paths are automated-tested; this does not claim live-server verification.
|
||||
|
||||
Inventory and YAML are **not crash-atomic**. A durable `transfers` journal reserves payouts before inventory delivery and acknowledges known outcomes afterward. Interrupted `RESERVED` transfers are quarantined, never automatically paid again. Players see recovery guidance; administrators must reconcile the exact owner/items against inventory and server records before any repair. Preserve `quests.yml`, player data and recovery logs; stop the server before a separately reviewed repair. Do not indiscriminately delete reservations or replay them: delivery may already have occurred.
|
||||
|
||||
Controlled input-save failures return the exact items without overwriting unrelated inventory changes. Offline/failed returns are retained for collection; unresolved disk failures emit recovery records and an explicit `INCOMPLETE` shutdown result. Keep those logs private: they contain player UUIDs and exact item data needed for recovery. Permanently unavailable storage, forced termination and the input-removal-before-save crash window cannot be made loss-proof by this file-based plugin.
|
||||
|
||||
A canonical-path ownership lock prevents a replacement runtime from loading a stale snapshot while accepted work drains. Do not delete `quests.yml.owner.lock` while a runtime is active. Unresolved hot reload is rejected rather than discarding recovery. Orderly shutdown closes main-thread callbacks, drains worker recovery and has a bounded 30-second JVM shutdown wait; it does not protect against SIGKILL, forced halt or indefinitely hung storage.
|
||||
|
||||
**Back up before upgrade. Do not run an older plugin against new bounty/transfer state.** Old binaries neither preserve these records nor honor the new lock; rollback requires a reviewed state-reconciliation/restore plan, not merely swapping the JAR.
|
||||
|
||||
## Distribution migration
|
||||
|
||||
The repository, checkout and new JARs use `purpur-quest-board`. Runtime `SpigotQuestBoard`, Java entrypoint/packages, data folder/namespace, commands, permissions and existing board/quest/escrow/claim data remain unchanged. Replace the old JAR instead of installing both distributions. Historical tags and `spigot-quest-board-*` assets are retained; the previous v1.12.0 download was verified byte-for-byte after renaming.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -21,9 +47,9 @@ Administrators can register persistent shared quest boards by targeting a block
|
||||
|
||||
The plugin JAR is written to `build/libs/`.
|
||||
|
||||
## Planned player commands
|
||||
## Player commands
|
||||
|
||||
Player commands are disabled by default and can be enabled by an administrator.
|
||||
Player commands are disabled by default and can be enabled persistently by an administrator. When disabled, players within five blocks of a registered board can still use `/quests create <item> <quantity>` with material and quantity autocomplete; other player command forms remain disabled.
|
||||
|
||||
```text
|
||||
/quests
|
||||
@@ -34,12 +60,13 @@ Player commands are disabled by default and can be enabled by an administrator.
|
||||
/quests claim
|
||||
```
|
||||
|
||||
## Planned administration
|
||||
## Administration
|
||||
|
||||
The `spigotquestboard.admin` permission is granted to server operators by default.
|
||||
|
||||
```text
|
||||
/questadmin createboard
|
||||
/questadmin createboard physical
|
||||
/questadmin commands <enable|disable>
|
||||
```
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ dependencies {
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
dependsOn(tasks.jar)
|
||||
systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath)
|
||||
systemProperty("distribution.version", project.version.toString())
|
||||
}
|
||||
|
||||
val pluginVersion = version
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
type: Index
|
||||
title: Spigot Quest Board Knowledge
|
||||
description: Entry point for the Spigot Quest Board OKF knowledge bundle.
|
||||
okf_version: "0.1"
|
||||
---
|
||||
|
||||
# Spigot Quest Board Knowledge
|
||||
|
||||
This bundle specifies shared physical quest boards, reward escrow, block deliveries, item claims, player commands, administration, persistence, and plugin delivery requirements.
|
||||
|
||||
## Explore
|
||||
|
||||
- [User stories](user-stories/index.md)
|
||||
- [Knowledge log](log.md)
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
type: Log
|
||||
title: Spigot Quest Board Knowledge Log
|
||||
description: Chronological record of material decisions affecting Spigot Quest Board.
|
||||
---
|
||||
|
||||
# Spigot Quest Board Knowledge Log
|
||||
|
||||
## 2026-09-05 — Initial quest-board design
|
||||
|
||||
- Administrators create physical quest boards, and every board exposes the same global quests and claims.
|
||||
- Players create quests by selecting a requested block material and quantity and escrowing reward items from their inventory.
|
||||
- Completing players deliver the requested block items at a board and immediately receive the escrowed reward.
|
||||
- Delivered blocks remain held by the quest system until the issuer claims them at a board.
|
||||
- Cancelled and seven-day-expired quests return escrowed rewards through the same claim system rather than direct inventory delivery.
|
||||
- Inventory overflow from rewards or claims is dropped at the receiving player's feet.
|
||||
- Player `/quests` commands are disabled by default so administrators can require physical-board interaction.
|
||||
- The board interface follows Spigot Base by using Purpur's supported native dialog API.
|
||||
|
||||
## 2026-09-05 — Foundation started
|
||||
|
||||
- Approved work begins with an OKF knowledge bundle, Java 25/Purpur 26.2 Gradle foundation, automated verification, and a public Gitea repository.
|
||||
|
||||
## 2026-09-05 — Foundation completed
|
||||
|
||||
- Published the project to the public `dmg/spigot-quest-board` Gitea repository.
|
||||
- Added strict Java 25/Purpur 26.2 Gradle builds, plugin metadata, a metadata regression test, and CI/release workflows modeled on Spigot Base.
|
||||
- Verified the plugin foundation and JAR with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-09-05 — Shared physical quest boards
|
||||
|
||||
- Added durable world-UUID and block-coordinate board registration through `/questadmin createboard`.
|
||||
- Registered main-hand interactions open one shared native Purpur dialog while unregistered blocks remain untouched.
|
||||
- Persistence publishes a board only after an atomic YAML save succeeds and rejects duplicate locations.
|
||||
- Verified 11 tests and the plugin JAR with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-09-05 — Quest creation and reward escrow
|
||||
|
||||
- Added board-dialog and `/quests create <block> <quantity>` creation using a valid block material, positive quantity, and the exact held reward stack.
|
||||
- Escrow preserves full Bukkit item metadata in durable Base64-backed YAML and rolls inventory removal back if persistence fails.
|
||||
- Quests receive UUID identifiers and UTC creation and seven-day expiration timestamps.
|
||||
- Verified 26 tests and the plugin JAR with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-09-05 — Shared quest browsing
|
||||
|
||||
- Added globally shared active-quest listings to every board and to `/quests` and `/quests list`.
|
||||
- Listings include requested blocks, exact reward summaries, issuer names, and boundary-safe remaining durations.
|
||||
- Added persisted lifecycle status with backward-compatible active defaults and filtered stale quest IDs from contextual completion and cancellation suggestions.
|
||||
- Verified 37 tests and the plugin JAR with `./gradlew clean check jar`.
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
type: Index
|
||||
title: Spigot Quest Board User Stories
|
||||
description: Catalog of user stories for the Spigot Quest Board plugin.
|
||||
---
|
||||
|
||||
# Spigot Quest Board User Stories
|
||||
|
||||
1. [US-001: Build and release the plugin](us-001-build-and-release-plugin.md)
|
||||
2. [US-002: Create and use shared quest boards](us-002-create-and-use-shared-quest-boards.md)
|
||||
3. [US-003: Create a block-delivery quest](us-003-create-a-block-delivery-quest.md)
|
||||
4. [US-004: Browse available quests](us-004-browse-available-quests.md)
|
||||
5. [US-005: Deliver blocks and complete a quest](us-005-deliver-blocks-and-complete-a-quest.md)
|
||||
6. [US-006: Cancel an owned quest](us-006-cancel-an-owned-quest.md)
|
||||
7. [US-007: Expire quests and claim held items](us-007-expire-quests-and-claim-held-items.md)
|
||||
8. [US-008: Control player quest commands](us-008-control-player-quest-commands.md)
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-001: Build and release the plugin"
|
||||
description: Give maintainers repeatable Purpur builds, automated verification, and versioned public Gitea releases.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-001: Build and release the plugin
|
||||
|
||||
As a **plugin maintainer**, I want automated builds and releases modeled on Spigot Base so that tested, correctly versioned artifacts can be distributed consistently.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] The Gradle project compiles against Purpur API `26.2.build.2618-stable` using a Java 25 toolchain.
|
||||
- [x] Compiler lint warnings fail the build.
|
||||
- [x] Automated JUnit 5 tests run as part of the Gradle check lifecycle.
|
||||
- [x] Plugin metadata declares the entrypoint, player and admin commands, and permissions.
|
||||
- [x] Pushes and pull requests build and test the plugin in Gitea Actions.
|
||||
- [x] Pull requests validate conventional commit messages.
|
||||
- [x] CI stores a development JAR as a workflow artifact.
|
||||
- [x] Main-branch conventional commits drive semantic versioning.
|
||||
- [x] A successful release builds a versioned JAR and attaches it to the corresponding Gitea release.
|
||||
- [x] The project is published in the public `dmg/spigot-quest-board` Gitea repository.
|
||||
- [x] Build files, Gradle wrapper, workflows, and release behavior follow `../spigot-base/` where applicable while using Spigot Quest Board names and identifiers.
|
||||
|
||||
## Related
|
||||
|
||||
- [User-story catalog](index.md)
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-002: Create and use shared quest boards"
|
||||
description: Let administrators establish persistent physical boards that expose one shared quest system.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-002: Create and use shared quest boards
|
||||
|
||||
As an **administrator**, I want to turn a targeted block into a quest board so that players can interact with quests at controlled physical locations.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] An authorized administrator can use `/questadmin createboard` to register the block they are targeting.
|
||||
- [x] Creation rejects a missing, invalid, or already registered target without changing state.
|
||||
- [x] Registered boards persist across server restarts with their world and block coordinates.
|
||||
- [x] Interacting with any registered board opens the quest-board interface.
|
||||
- [x] Every registered board exposes the same global quests and claimable items.
|
||||
- [x] The interface uses Purpur's supported native dialog API and follows the interaction style of Spigot Base.
|
||||
- [x] Ordinary block interaction is not intercepted at unregistered locations.
|
||||
- [x] Administrative actions require the `spigotquestboard.admin` permission, granted to server operators by default.
|
||||
- [x] Automated tests verify board registration, persistence, shared visibility, authorization, and interaction routing.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-003: Create a block-delivery quest](us-003-create-a-block-delivery-quest.md)
|
||||
- [US-004: Browse available quests](us-004-browse-available-quests.md)
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-003: Create a block-delivery quest"
|
||||
description: Let a player request block items while safely escrowing an inventory-funded reward.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-003: Create a block-delivery quest
|
||||
|
||||
As a **player**, I want to request a block material and quantity in exchange for items I already own so that another player can fulfill my request safely.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] A player can create a quest through a registered quest board by selecting a valid block material, a positive quantity, and reward items from their inventory.
|
||||
- [x] Reward items must exist in the issuer's inventory and are removed only when quest creation succeeds.
|
||||
- [x] Removed rewards are held durably in escrow by the quest system.
|
||||
- [x] A created quest records a unique identifier, issuer UUID and current name, requested material and quantity, exact reward items, creation time, and expiration time.
|
||||
- [x] Each quest expires exactly seven days after successful creation.
|
||||
- [x] Invalid input, insufficient reward items, or persistence failure leaves the issuer's inventory unchanged and creates no quest.
|
||||
- [x] When player commands are enabled, `/quests create ...` provides equivalent validated creation behavior with contextual autocomplete.
|
||||
- [x] Quest state and item metadata survive server restarts without duplication or loss.
|
||||
- [x] Automated tests verify validation, escrow, rollback, timestamps, command routing, and persistence.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-002: Create and use shared quest boards](us-002-create-and-use-shared-quest-boards.md)
|
||||
- [US-005: Deliver blocks and complete a quest](us-005-deliver-blocks-and-complete-a-quest.md)
|
||||
- [US-007: Expire quests and claim held items](us-007-expire-quests-and-claim-held-items.md)
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-004: Browse available quests"
|
||||
description: Let players inspect globally available quests and their essential terms.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-004: Browse available quests
|
||||
|
||||
As a **player**, I want to browse current quests so that I can decide which block deliveries to fulfill.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Every registered board lists the same active quests.
|
||||
- [x] Each listing shows the requested block material, quantity, reward, issuer, and time remaining.
|
||||
- [x] Remaining durations are clear and never display an expired quest as active.
|
||||
- [x] Completed, cancelled, and expired quests are excluded from the active listing.
|
||||
- [x] When player commands are enabled, `/quests` and `/quests list` provide the same active quest information in chat.
|
||||
- [x] Listing and autocomplete do not expose stale quest identifiers as completable or cancellable.
|
||||
- [x] Automated tests verify filtering, displayed fields, duration boundaries, and command aliases.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-002: Create and use shared quest boards](us-002-create-and-use-shared-quest-boards.md)
|
||||
- [US-005: Deliver blocks and complete a quest](us-005-deliver-blocks-and-complete-a-quest.md)
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-005: Deliver blocks and complete a quest"
|
||||
description: Let a player deliver requested blocks at a quest board and receive the escrowed reward.
|
||||
status: backlog
|
||||
---
|
||||
|
||||
# US-005: Deliver blocks and complete a quest
|
||||
|
||||
As a **player**, I want to deliver requested blocks to a quest board so that I receive the promised reward and the issuer can claim my delivery.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] An active quest offers a Complete Quest action through every registered board.
|
||||
- [ ] Completion requires the delivering player to possess the full requested quantity of the exact block material.
|
||||
- [ ] Successful completion removes the requested blocks from the delivering player's inventory and stores them as a claim for the issuer.
|
||||
- [ ] Successful completion gives the exact escrowed reward items to the delivering player.
|
||||
- [ ] Reward items that do not fit are dropped safely at the delivering player's feet with a clear message.
|
||||
- [ ] The issuer is notified immediately when online or on their next login when offline that delivered blocks can be claimed at a quest board.
|
||||
- [ ] When player commands are enabled, `/quests complete <quest>` provides equivalent behavior with contextual autocomplete.
|
||||
- [ ] Completion is atomic and exactly one player can complete a quest, including simultaneous attempts.
|
||||
- [ ] Validation or persistence failure does not consume delivered blocks, release rewards, or change quest state.
|
||||
- [ ] Completed quests are no longer shown as active or completable.
|
||||
- [ ] Automated tests verify inventory validation, settlement, overflow, notifications, rollback, and concurrent completion protection.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-003: Create a block-delivery quest](us-003-create-a-block-delivery-quest.md)
|
||||
- [US-007: Expire quests and claim held items](us-007-expire-quests-and-claim-held-items.md)
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-006: Cancel an owned quest"
|
||||
description: Let an issuer cancel an active quest and reclaim its escrowed reward safely.
|
||||
status: backlog
|
||||
---
|
||||
|
||||
# US-006: Cancel an owned quest
|
||||
|
||||
As a **quest issuer**, I want to cancel my active quest so that I can reclaim the reward when I no longer need the requested blocks.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] An issuer can cancel their own active quest through any registered board.
|
||||
- [ ] A player cannot cancel a quest issued by another player.
|
||||
- [ ] Completed, cancelled, and expired quests cannot be cancelled.
|
||||
- [ ] Cancellation removes the quest from active listings and converts its exact escrowed reward into a claim for the issuer.
|
||||
- [ ] The reward is not inserted directly into the issuer's inventory during cancellation.
|
||||
- [ ] When player commands are enabled, `/quests cancel <quest>` provides equivalent behavior and only autocompletes the player's cancellable quest identifiers.
|
||||
- [ ] Cancellation and simultaneous completion are serialized so items cannot be duplicated or lost.
|
||||
- [ ] Persistence failure leaves the quest active and its reward escrowed.
|
||||
- [ ] Automated tests verify ownership, state validation, claim creation, autocomplete, rollback, and completion races.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-003: Create a block-delivery quest](us-003-create-a-block-delivery-quest.md)
|
||||
- [US-007: Expire quests and claim held items](us-007-expire-quests-and-claim-held-items.md)
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-007: Expire quests and claim held items"
|
||||
description: Hold delivered blocks and returned rewards until their owner safely claims them.
|
||||
status: backlog
|
||||
---
|
||||
|
||||
# US-007: Expire quests and claim held items
|
||||
|
||||
As a **quest issuer**, I want delivered blocks and returned rewards held at the shared quest board so that I can retrieve them safely.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] An uncompleted quest expires seven days after its creation time.
|
||||
- [ ] Expiration removes the quest from active listings and converts its exact escrowed reward into a claim for the issuer.
|
||||
- [ ] Completed-quest claims contain the exact block items delivered by the completing player.
|
||||
- [ ] Claims from completion, cancellation, and expiration can be collected through any registered board.
|
||||
- [ ] The board clearly distinguishes delivered blocks from returned rewards.
|
||||
- [ ] Online issuers receive a real-time message when items become claimable.
|
||||
- [ ] Offline issuers receive a message on their next login.
|
||||
- [ ] When player commands are enabled, `/quests claim` collects the same pending claims.
|
||||
- [ ] Items are inserted into the claimant's inventory where space permits; overflow is dropped safely at their feet with a clear message.
|
||||
- [ ] A claim is removed only after all of its items have been inserted or dropped successfully.
|
||||
- [ ] Pending claims and notification state survive logout and server restart without duplication or loss.
|
||||
- [ ] Automated tests verify expiry boundaries, each claim source, notifications, overflow, failure recovery, and persistence.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-005: Deliver blocks and complete a quest](us-005-deliver-blocks-and-complete-a-quest.md)
|
||||
- [US-006: Cancel an owned quest](us-006-cancel-an-owned-quest.md)
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-008: Control player quest commands"
|
||||
description: Let administrators require physical-board interaction by controlling access to player quest commands.
|
||||
status: backlog
|
||||
---
|
||||
|
||||
# US-008: Control player quest commands
|
||||
|
||||
As an **administrator**, I want to enable or disable player quest commands so that I can require players to use physical quest boards.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Player `/quests` commands are disabled by default.
|
||||
- [ ] An authorized administrator can use `/questadmin commands enable|disable` with contextual autocomplete.
|
||||
- [ ] The command setting persists across server restarts.
|
||||
- [ ] When disabled, `/quests`, `list`, `create`, `complete`, `cancel`, and `claim` reject execution with a clear message directing the player to a quest board.
|
||||
- [ ] Disabling commands does not prevent any equivalent action through a registered board.
|
||||
- [ ] `/questadmin` remains available to authorized administrators regardless of the player-command setting.
|
||||
- [ ] Unauthorized users cannot change the setting.
|
||||
- [ ] Automated tests verify the default, persistence, authorization, every gated subcommand, and autocomplete.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-002: Create and use shared quest boards](us-002-create-and-use-shared-quest-boards.md)
|
||||
- [US-003: Create a block-delivery quest](us-003-create-a-block-delivery-quest.md)
|
||||
- [US-007: Expire quests and claim held items](us-007-expire-quests-and-claim-held-items.md)
|
||||
+1
-1
@@ -2,4 +2,4 @@ plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||
}
|
||||
|
||||
rootProject.name = "spigot-quest-board"
|
||||
rootProject.name = "purpur-quest-board"
|
||||
@@ -0,0 +1,488 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.List;
|
||||
import java.util.Queue;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.function.Function;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/**
|
||||
* Runtime mutation boundary: supply ONE worker executor and a Bukkit main-thread executor.
|
||||
* Do not wire synchronous mutation gateways alongside this adapter. The caller owns shutdown.
|
||||
* RESERVED transfers found at startup are uncertain and require manual reconciliation: YAML
|
||||
* and player inventories cannot be committed atomically. Input removal also has a crash window
|
||||
* before escrow/return persistence; transient failures are recoverable, not crash-atomic.
|
||||
*/
|
||||
final class AsyncQuestActions implements QuestActions {
|
||||
private final QuestService service;
|
||||
private final Executor worker;
|
||||
private final Executor main;
|
||||
private final QuestClaimInventory inventory;
|
||||
private final QuestInputInventory inputs;
|
||||
private final ItemMaterialCatalog materials;
|
||||
private final Function<UUID, Player> onlinePlayers;
|
||||
private final Logger logger;
|
||||
// Known outcomes survive rejected executor submissions in memory. Never reconstruct these
|
||||
// from RESERVED records on restart: that would guess whether an inventory received items.
|
||||
private final Map<UUID, Boolean> settlements = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, ClaimTransfer> pendingReturns = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, Persistence> pendingOperations = new ConcurrentHashMap<>();
|
||||
private final Queue<Death> pendingDeaths = new ArrayDeque<>(); // Worker-confined FIFO.
|
||||
private record Death(UUID id, UUID target, UUID killer, Instant at) { }
|
||||
|
||||
AsyncQuestActions(QuestService service, Executor worker, Executor main, Logger logger) {
|
||||
this(service, worker, main, new BukkitQuestInputInventory(new BukkitQuestClaimInventory()),
|
||||
new BukkitQuestClaimInventory(), new BukkitItemMaterialCatalog(), org.bukkit.Bukkit::getPlayer, logger);
|
||||
}
|
||||
|
||||
AsyncQuestActions(QuestService service, Executor worker, Executor main,
|
||||
QuestClaimInventory inventory, Logger logger) {
|
||||
this(service, worker, main, new BukkitQuestInputInventory(inventory), inventory,
|
||||
new BukkitItemMaterialCatalog(), id -> null, logger);
|
||||
}
|
||||
|
||||
AsyncQuestActions(QuestService service, Executor worker, Executor main,
|
||||
QuestInputInventory inputs, QuestClaimInventory inventory,
|
||||
ItemMaterialCatalog materials, Function<UUID, Player> onlinePlayers,
|
||||
Logger logger) {
|
||||
this.service = Objects.requireNonNull(service);
|
||||
this.worker = Objects.requireNonNull(worker);
|
||||
this.main = Objects.requireNonNull(main);
|
||||
this.inventory = Objects.requireNonNull(inventory);
|
||||
this.inputs = Objects.requireNonNull(inputs);
|
||||
this.materials = Objects.requireNonNull(materials);
|
||||
this.onlinePlayers = Objects.requireNonNull(onlinePlayers);
|
||||
this.logger = Objects.requireNonNull(logger);
|
||||
service.state().transfers().values().stream()
|
||||
.filter(t -> t.status() == ClaimTransfer.Status.RESERVED)
|
||||
.forEach(t -> logger.warning("Uncertain quest transfer quarantined; manual reconciliation required: " + t.id()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void create(Player player, String material, int amount, Instant at) {
|
||||
try {
|
||||
requireLive(player);
|
||||
Objects.requireNonNull(at, "createdAt");
|
||||
if (amount <= 0) {
|
||||
throw new IllegalArgumentException("Requested quantity must be positive");
|
||||
}
|
||||
String normalized = materials.normalizeItem(material).orElseThrow(
|
||||
() -> new IllegalArgumentException("Requested material must be a valid item"));
|
||||
UUID owner = player.getUniqueId();
|
||||
String name = player.getName();
|
||||
QuestInputInventory.Reservation removed = inputs.removeReward(player);
|
||||
List<EscrowItem> items = List.copyOf(removed.items());
|
||||
saveInput(player, owner, removed, items, () -> {
|
||||
Quest quest = service.create(owner, name, normalized, amount, items, at);
|
||||
return "Quest " + quest.id() + " created. Your exact reward is held in escrow.";
|
||||
});
|
||||
} catch (RuntimeException exception) {
|
||||
failure(player, exception.getMessage(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postBounty(Player player, UUID targetId, String targetName, int kills, Instant at) {
|
||||
try {
|
||||
requireLive(player);
|
||||
Objects.requireNonNull(at, "createdAt");
|
||||
Objects.requireNonNull(targetId, "targetId");
|
||||
if (kills <= 0 || targetName == null || targetName.isBlank()) {
|
||||
throw new IllegalArgumentException("A target player and a positive kill count are required.");
|
||||
}
|
||||
UUID owner = player.getUniqueId();
|
||||
String name = player.getName();
|
||||
QuestInputInventory.Reservation removed = inputs.removeReward(player);
|
||||
List<EscrowItem> items = List.copyOf(removed.items());
|
||||
saveInput(player, owner, removed, items, () -> {
|
||||
Bounty bounty = service.createBounty(owner, name, targetId, targetName, kills, items, at);
|
||||
return "Bounty " + bounty.id() + " posted. Your exact reward is held in escrow.";
|
||||
});
|
||||
} catch (RuntimeException exception) {
|
||||
failure(player, exception.getMessage(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveInput(Player player, UUID owner, QuestInputInventory.Reservation removed,
|
||||
List<EscrowItem> items, SavedInput operation) {
|
||||
Runnable save = () -> {
|
||||
retryRecovery();
|
||||
final String message;
|
||||
try {
|
||||
message = operation.save();
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
logger.log(Level.WARNING, "Quest input could not be saved; returning exact items", exception);
|
||||
returnInput(player, owner, removed, items);
|
||||
return;
|
||||
}
|
||||
onMain(() -> feedback(player, message));
|
||||
};
|
||||
if (!onWorker(save)) {
|
||||
returnInput(player, owner, removed, items);
|
||||
}
|
||||
}
|
||||
|
||||
private void returnInput(Player player, UUID owner, QuestInputInventory.Reservation removed,
|
||||
List<EscrowItem> items) {
|
||||
ClaimTransfer recovery = new ClaimTransfer(UUID.randomUUID(), owner, null, items, ClaimTransfer.Status.PENDING);
|
||||
if (!onMain(() -> {
|
||||
try {
|
||||
requireLive(player);
|
||||
removed.returnTo(player);
|
||||
feedback(player, "Quest was not saved. Your exact items were returned.");
|
||||
} catch (RuntimeException exception) {
|
||||
logger.log(Level.WARNING, "Input return deferred: " + recovery.id(), exception);
|
||||
retainReturn(recovery);
|
||||
feedback(player, "Quest was not saved. Your exact items are pending recovery; collect them at a board.");
|
||||
}
|
||||
})) {
|
||||
retainReturn(recovery);
|
||||
}
|
||||
}
|
||||
|
||||
private void retainReturn(ClaimTransfer recovery) {
|
||||
pendingReturns.put(recovery.id(), recovery);
|
||||
onWorker(this::retryRecovery);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void complete(Player player, String id, Instant at) {
|
||||
try {
|
||||
requireLive(player);
|
||||
UUID questId = parseId(id);
|
||||
UUID collector = player.getUniqueId();
|
||||
Quest quest = service.completionCandidate(questId, at);
|
||||
QuestInputInventory.Reservation removed = inputs.removeDelivery(
|
||||
player, quest.requestedMaterial(), quest.requestedAmount());
|
||||
List<EscrowItem> items = List.copyOf(removed.items());
|
||||
if (!onWorker(() -> {
|
||||
retryRecovery();
|
||||
final ClaimTransfer reward;
|
||||
try {
|
||||
reward = service.completeFor(questId, collector, items, at);
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
logger.log(Level.WARNING, "Quest completion failed; returning exact delivery", exception);
|
||||
returnInput(player, collector, removed, items);
|
||||
return;
|
||||
}
|
||||
if (!onMain(() -> {
|
||||
feedback(player, "Quest " + questId + " completed. Your exact escrowed reward is ready.");
|
||||
deliver(player, reward);
|
||||
notifyRecipient(quest.issuerId());
|
||||
})) {
|
||||
settle(reward, false);
|
||||
}
|
||||
})) {
|
||||
returnInput(player, collector, removed, items);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
failure(player, exception.getMessage(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void cancel(Player player, String id, Instant at) {
|
||||
cancel(player, id, at, false);
|
||||
}
|
||||
|
||||
@Override public void cancelBounty(Player player, String id, Instant at) {
|
||||
cancel(player, id, at, true);
|
||||
}
|
||||
|
||||
private void cancel(Player player, String id, Instant at, boolean bounty) {
|
||||
try {
|
||||
UUID parsed = parseId(id);
|
||||
UUID owner = player.getUniqueId();
|
||||
if (!onWorker(() -> {
|
||||
retryRecovery();
|
||||
try {
|
||||
if (bounty) {
|
||||
requireDeathOrder();
|
||||
service.cancelBounty(parsed, owner, at);
|
||||
} else {
|
||||
service.cancel(parsed, owner, at);
|
||||
}
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
failure(player, "Cancellation failed: " + exception.getMessage(), exception);
|
||||
return;
|
||||
}
|
||||
onMain(() -> feedback(player, (bounty ? "Bounty " : "Quest ") + parsed
|
||||
+ " cancelled. Your exact escrowed reward is ready to claim at a quest board."));
|
||||
})) {
|
||||
feedback(player, "Quest worker unavailable; cancellation was not saved.");
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
failure(player, exception.getMessage(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static UUID parseId(String id) {
|
||||
try {
|
||||
return UUID.fromString(Objects.requireNonNull(id));
|
||||
} catch (IllegalArgumentException | NullPointerException exception) {
|
||||
throw new IllegalArgumentException("Quest identifier must be a valid UUID.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void collect(Player player) {
|
||||
UUID owner = player.getUniqueId();
|
||||
if (!onWorker(() -> collectOnWorker(player, owner))) {
|
||||
feedback(player, "Quest worker unavailable; no claims were delivered.");
|
||||
}
|
||||
}
|
||||
|
||||
private void collectOnWorker(Player player, UUID owner) {
|
||||
retryRecovery();
|
||||
final List<ClaimTransfer> transfers;
|
||||
try {
|
||||
transfers = service.reserveClaims(owner);
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
failure(player, "Could not reserve quest claims; no items were delivered.", exception);
|
||||
return;
|
||||
}
|
||||
if (!onMain(() -> {
|
||||
if (transfers.isEmpty()) {
|
||||
boolean pending = !service.claimsFor(owner).isEmpty()
|
||||
|| service.state().transfers().values().stream().anyMatch(t -> t.ownerId().equals(owner))
|
||||
|| pendingReturns.values().stream().anyMatch(t -> t.ownerId().equals(owner));
|
||||
feedback(player, pending
|
||||
? "No collectible quest claims. Pending transfers may require recovery."
|
||||
: "You have no pending quest claims.");
|
||||
}
|
||||
transfers.forEach(transfer -> deliver(player, transfer));
|
||||
})) {
|
||||
transfers.forEach(transfer -> settlements.put(transfer.id(), false));
|
||||
retryRecovery();
|
||||
}
|
||||
}
|
||||
|
||||
private void deliver(Player player, ClaimTransfer transfer) {
|
||||
String description = describe(transfer);
|
||||
final QuestClaimInventory.Delivery delivery;
|
||||
try {
|
||||
requireLive(player);
|
||||
delivery = inventory.prepare(transfer.items()).deliver(player);
|
||||
} catch (RuntimeException exception) {
|
||||
// prepare/deliver must undo partial delivery within this callback, never after yielding.
|
||||
settle(transfer, false);
|
||||
failure(player, "Quest delivery failed; your items remain pending.", exception);
|
||||
return;
|
||||
}
|
||||
// No path after this point may release the reservation or roll back inventory snapshots.
|
||||
settle(transfer, true);
|
||||
feedback(player, description);
|
||||
if (delivery.overflowAmount() > 0) {
|
||||
feedback(player, delivery.overflowAmount()
|
||||
+ " item(s) did not fit and were dropped at your feet, protected for you.");
|
||||
}
|
||||
}
|
||||
|
||||
private String describe(ClaimTransfer transfer) {
|
||||
if (transfer.claimId() != null) {
|
||||
for (QuestClaim claim : service.claimsFor(transfer.ownerId())) {
|
||||
if (!claim.id().equals(transfer.claimId())) {
|
||||
continue;
|
||||
}
|
||||
return switch (claim.source()) {
|
||||
case BOUNTY -> "Collected earned bounty reward from bounty " + claim.questId() + ".";
|
||||
case COMPLETION -> "Collected delivered items from completed quest " + claim.questId() + ".";
|
||||
case CANCELLATION -> "Collected returned reward from cancelled quest " + claim.questId() + ".";
|
||||
case EXPIRATION -> "Collected returned reward from expired quest " + claim.questId() + ".";
|
||||
};
|
||||
}
|
||||
}
|
||||
if (service.state().quests().containsKey(transfer.id())) {
|
||||
return "Collected exact escrowed reward from completed quest " + transfer.id() + ".";
|
||||
}
|
||||
return "Collected recovered quest input items (transfer " + transfer.id() + ").";
|
||||
}
|
||||
|
||||
private void settle(ClaimTransfer transfer, boolean delivered) {
|
||||
settlements.put(transfer.id(), delivered);
|
||||
onWorker(this::retryRecovery);
|
||||
}
|
||||
|
||||
/** Called on the persistence lane after main callbacks have been closed and drained. */
|
||||
boolean shutdownRecovery() {
|
||||
retryRecovery();
|
||||
boolean complete = pendingDeaths.isEmpty() && pendingOperations.isEmpty()
|
||||
&& settlements.isEmpty() && pendingReturns.isEmpty();
|
||||
if (!complete) {
|
||||
// Records include the exact serialized items and original death attribution/time.
|
||||
// Keep these queues alive with the persistence owner; never release it on failure.
|
||||
logger.severe("Quest shutdown INCOMPLETE; persistence ownership retained. Recoverable obligations: "
|
||||
+ "returns=" + List.copyOf(pendingReturns.values())
|
||||
+ "; deaths=" + List.copyOf(pendingDeaths)
|
||||
+ "; known delivery outcomes=" + Map.copyOf(settlements)
|
||||
+ "; notification acknowledgements=" + pendingOperations.keySet()
|
||||
+ "; durable transfers=" + service.state().transfers());
|
||||
}
|
||||
return complete;
|
||||
}
|
||||
|
||||
private void retryRecovery() {
|
||||
while (!pendingDeaths.isEmpty()) {
|
||||
Death death = pendingDeaths.element();
|
||||
if (!retry("Player death " + death.id(), () -> {
|
||||
List<QuestClaim> earned = service.recordDeath(death.id(), death.target(), death.killer(), death.at());
|
||||
onMain(() -> earned.forEach(claim -> notifyRecipient(claim.ownerId())));
|
||||
})) {
|
||||
break;
|
||||
}
|
||||
pendingDeaths.remove();
|
||||
}
|
||||
for (Map.Entry<UUID, Persistence> entry : Map.copyOf(pendingOperations).entrySet()) {
|
||||
retry("Quest operation " + entry.getKey(), () -> {
|
||||
entry.getValue().run();
|
||||
pendingOperations.remove(entry.getKey());
|
||||
});
|
||||
}
|
||||
for (Map.Entry<UUID, Boolean> entry : Map.copyOf(settlements).entrySet()) {
|
||||
retry("Transfer settlement " + entry.getKey(), () -> {
|
||||
if (entry.getValue()) {
|
||||
service.acknowledgeTransfer(entry.getKey());
|
||||
} else {
|
||||
service.releaseTransfer(entry.getKey());
|
||||
}
|
||||
settlements.remove(entry.getKey());
|
||||
});
|
||||
}
|
||||
for (ClaimTransfer recovery : List.copyOf(pendingReturns.values())) {
|
||||
retry("Input recovery " + recovery.id(), () -> {
|
||||
service.retainReturn(recovery);
|
||||
pendingReturns.remove(recovery.id());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private boolean retry(String description, Persistence operation) {
|
||||
for (int attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
operation.run();
|
||||
return true;
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
logger.log(Level.SEVERE, description + " failed; retained for retry", exception);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean onWorker(Runnable task) {
|
||||
try {
|
||||
worker.execute(task);
|
||||
return true;
|
||||
} catch (RuntimeException exception) {
|
||||
logger.log(Level.SEVERE, "Quest worker rejected operation; recovery may require attention", exception);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean onMain(Runnable task) {
|
||||
try {
|
||||
main.execute(task);
|
||||
return true;
|
||||
} catch (RuntimeException exception) {
|
||||
logger.log(Level.SEVERE, "Quest main callback rejected", exception);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void failure(Player player, String message, Exception exception) {
|
||||
logger.log(Level.WARNING, message, exception);
|
||||
onMain(() -> feedback(player, message == null ? "Quest action failed." : message));
|
||||
}
|
||||
|
||||
private boolean feedback(Player player, String message) {
|
||||
try {
|
||||
player.sendMessage(message);
|
||||
return true;
|
||||
} catch (RuntimeException exception) {
|
||||
logger.log(Level.WARNING, "Could not send quest feedback", exception);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireLive(Player player) {
|
||||
if (!player.isOnline() || player.isDead()) {
|
||||
throw new IllegalStateException("Quest items require an online, living player; items remain pending.");
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface private interface SavedInput { String save() throws IOException; }
|
||||
@FunctionalInterface private interface Persistence { void run() throws IOException; }
|
||||
|
||||
@Override
|
||||
public void death(UUID deathId, UUID targetId, UUID killerId, Instant at) {
|
||||
Objects.requireNonNull(deathId);
|
||||
Objects.requireNonNull(targetId);
|
||||
Objects.requireNonNull(at);
|
||||
if (!onWorker(() -> {
|
||||
pendingDeaths.add(new Death(deathId, targetId, killerId, at));
|
||||
retryRecovery();
|
||||
})) {
|
||||
logger.severe("Death could not be queued and requires attention: " + deathId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void expire(Instant at) {
|
||||
onWorker(() -> {
|
||||
retryRecovery();
|
||||
try {
|
||||
requireDeathOrder();
|
||||
List<QuestClaim> expired = service.expire(at);
|
||||
onMain(() -> expired.stream().map(QuestClaim::ownerId).distinct().forEach(this::notifyRecipient));
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
logger.log(Level.SEVERE, "Quest expiry failed; next expiry tick will retry", exception);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void requireDeathOrder() throws IOException {
|
||||
if (!pendingDeaths.isEmpty()) {
|
||||
throw new IOException("Earlier death events still await persistence; retry this action.");
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyRecipient(UUID id) {
|
||||
try {
|
||||
Player recipient = onlinePlayers.apply(id);
|
||||
if (recipient != null && recipient.isOnline()) {
|
||||
joined(recipient);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
logger.log(Level.WARNING, "Quest notification deferred for " + id, exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void joined(Player player) {
|
||||
UUID owner = player.getUniqueId();
|
||||
onWorker(this::retryRecovery);
|
||||
for (IssuerNotification notification : service.pendingNotifications(owner)) {
|
||||
if (!pendingOperations.containsKey(notification.id()) && feedback(player, notification.message())) {
|
||||
pendingOperations.put(notification.id(), () -> service.markNotificationDelivered(notification.id()));
|
||||
onWorker(this::retryRecovery);
|
||||
}
|
||||
}
|
||||
long quarantined = service.state().transfers().values().stream()
|
||||
.filter(t -> t.ownerId().equals(owner) && t.status() == ClaimTransfer.Status.RESERVED).count();
|
||||
if (quarantined > 0) {
|
||||
feedback(player, quarantined + " quest transfer(s) are reserved and may require administrator reconciliation; not redelivered.");
|
||||
}
|
||||
long pending = service.claimsFor(owner).size() + service.state().transfers().values().stream()
|
||||
.filter(t -> t.ownerId().equals(owner) && t.claimId() == null).count();
|
||||
if (pending > 0 || pendingReturns.values().stream().anyMatch(t -> t.ownerId().equals(owner))) {
|
||||
feedback(player, "You have items waiting to be claimed. Visit the quest board to collect them.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import org.bukkit.block.BlockFace;
|
||||
|
||||
/** The direction from the board toward its viewer. */
|
||||
enum BoardFacing {
|
||||
NORTH(0, -1),
|
||||
EAST(1, 0),
|
||||
SOUTH(0, 1),
|
||||
WEST(-1, 0);
|
||||
|
||||
private final int x;
|
||||
private final int z;
|
||||
|
||||
BoardFacing(int x, int z) {
|
||||
this.x = x;
|
||||
this.z = z;
|
||||
}
|
||||
|
||||
int x() {
|
||||
return x;
|
||||
}
|
||||
|
||||
int z() {
|
||||
return z;
|
||||
}
|
||||
|
||||
int rightX() {
|
||||
return -z;
|
||||
}
|
||||
|
||||
int rightZ() {
|
||||
return x;
|
||||
}
|
||||
|
||||
BlockFace blockFace() {
|
||||
return switch (this) {
|
||||
case NORTH -> BlockFace.NORTH;
|
||||
case EAST -> BlockFace.EAST;
|
||||
case SOUTH -> BlockFace.SOUTH;
|
||||
case WEST -> BlockFace.WEST;
|
||||
};
|
||||
}
|
||||
|
||||
static BoardFacing towardPlayer(BlockFace playerFacing) {
|
||||
return switch (playerFacing) {
|
||||
case NORTH -> SOUTH;
|
||||
case EAST -> WEST;
|
||||
case SOUTH -> NORTH;
|
||||
case WEST -> EAST;
|
||||
default -> throw new IllegalArgumentException("Player must face a cardinal direction");
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@FunctionalInterface
|
||||
interface BoardProximity {
|
||||
boolean isWithin(UUID worldId, double x, double y, double z, double maximumDistance);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
final class BoardRegistry {
|
||||
final class BoardRegistry implements BoardProximity {
|
||||
private final BoardRepository repository;
|
||||
private Map<BoardId, RegisteredBoard> boards;
|
||||
|
||||
@@ -16,12 +17,22 @@ final class BoardRegistry {
|
||||
}
|
||||
|
||||
synchronized BoardRegistrationResult register(RegisteredBoard board) throws IOException {
|
||||
Objects.requireNonNull(board, "board");
|
||||
if (boards.containsKey(board.id())) {
|
||||
return BoardRegistrationResult.ALREADY_REGISTERED;
|
||||
}
|
||||
return registerAll(Set.of(Objects.requireNonNull(board, "board")));
|
||||
}
|
||||
|
||||
synchronized BoardRegistrationResult registerAll(Collection<RegisteredBoard> additions)
|
||||
throws IOException {
|
||||
Objects.requireNonNull(additions, "additions");
|
||||
Map<BoardId, RegisteredBoard> candidate = new LinkedHashMap<>(boards);
|
||||
candidate.put(board.id(), board);
|
||||
for (RegisteredBoard board : additions) {
|
||||
Objects.requireNonNull(board, "board");
|
||||
if (candidate.putIfAbsent(board.id(), board) != null) {
|
||||
return BoardRegistrationResult.ALREADY_REGISTERED;
|
||||
}
|
||||
}
|
||||
if (additions.isEmpty()) {
|
||||
return BoardRegistrationResult.CREATED;
|
||||
}
|
||||
repository.save(new BoardState(Set.copyOf(candidate.values())));
|
||||
boards = Map.copyOf(candidate);
|
||||
return BoardRegistrationResult.CREATED;
|
||||
@@ -35,6 +46,34 @@ final class BoardRegistry {
|
||||
return boards.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized boolean isWithin(
|
||||
java.util.UUID worldId,
|
||||
double x,
|
||||
double y,
|
||||
double z,
|
||||
double maximumDistance
|
||||
) {
|
||||
Objects.requireNonNull(worldId, "worldId");
|
||||
if (maximumDistance < 0.0) {
|
||||
return false;
|
||||
}
|
||||
double maximumDistanceSquared = maximumDistance * maximumDistance;
|
||||
return boards.keySet().stream()
|
||||
.filter(id -> id.worldId().equals(worldId))
|
||||
.anyMatch(id -> {
|
||||
double deltaX = x - id.x();
|
||||
double deltaY = y - id.y();
|
||||
double deltaZ = z - id.z();
|
||||
return deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ
|
||||
<= maximumDistanceSquared;
|
||||
});
|
||||
}
|
||||
|
||||
synchronized Map<BoardId, RegisteredBoard> registeredBoards() {
|
||||
return Map.copyOf(boards);
|
||||
}
|
||||
|
||||
private static Map<BoardId, RegisteredBoard> index(BoardState state) throws IOException {
|
||||
Map<BoardId, RegisteredBoard> indexed = new LinkedHashMap<>();
|
||||
for (RegisteredBoard board : state.boards()) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
record Bounty(
|
||||
UUID id,
|
||||
UUID issuerId,
|
||||
String issuerName,
|
||||
UUID targetId,
|
||||
String targetName,
|
||||
int requiredKills,
|
||||
List<EscrowItem> reward,
|
||||
Instant createdAt,
|
||||
Instant expiresAt,
|
||||
QuestStatus status,
|
||||
Map<UUID, Integer> progress,
|
||||
Set<UUID> processedDeaths,
|
||||
UUID winnerId
|
||||
) {
|
||||
Bounty {
|
||||
Objects.requireNonNull(id, "id");
|
||||
Objects.requireNonNull(issuerId, "issuerId");
|
||||
Objects.requireNonNull(targetId, "targetId");
|
||||
if (Objects.requireNonNull(issuerName, "issuerName").isBlank()
|
||||
|| Objects.requireNonNull(targetName, "targetName").isBlank()) {
|
||||
throw new IllegalArgumentException("Player names must not be blank");
|
||||
}
|
||||
if (requiredKills <= 0) {
|
||||
throw new IllegalArgumentException("Required kills must be positive");
|
||||
}
|
||||
reward = List.copyOf(Objects.requireNonNull(reward, "reward"));
|
||||
if (reward.isEmpty()) {
|
||||
throw new IllegalArgumentException("Reward must not be empty");
|
||||
}
|
||||
Objects.requireNonNull(createdAt, "createdAt");
|
||||
Objects.requireNonNull(expiresAt, "expiresAt");
|
||||
Objects.requireNonNull(status, "status");
|
||||
if (!expiresAt.isAfter(createdAt)) {
|
||||
throw new IllegalArgumentException("Expiration must follow creation");
|
||||
}
|
||||
progress = Map.copyOf(Objects.requireNonNull(progress, "progress"));
|
||||
processedDeaths = Set.copyOf(Objects.requireNonNull(processedDeaths, "processedDeaths"));
|
||||
long totalKills = 0;
|
||||
for (Map.Entry<UUID, Integer> entry : progress.entrySet()) {
|
||||
int kills = entry.getValue();
|
||||
if (entry.getKey().equals(issuerId) || entry.getKey().equals(targetId)
|
||||
|| kills <= 0 || kills > requiredKills) {
|
||||
throw new IllegalArgumentException("Invalid hunter progress");
|
||||
}
|
||||
if (kills == requiredKills && !entry.getKey().equals(winnerId)) {
|
||||
throw new IllegalArgumentException("Only the winner may reach the required kills");
|
||||
}
|
||||
totalKills += kills;
|
||||
}
|
||||
if (totalKills != processedDeaths.size()) {
|
||||
throw new IllegalArgumentException("Hunter progress does not match processed deaths");
|
||||
}
|
||||
if (status == QuestStatus.COMPLETED) {
|
||||
if (winnerId == null || progress.getOrDefault(winnerId, 0) != requiredKills) {
|
||||
throw new IllegalArgumentException("Completed bounty requires a qualifying winner");
|
||||
}
|
||||
} else if (winnerId != null) {
|
||||
throw new IllegalArgumentException("Only a completed bounty may have a winner");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.lang.ref.ReferenceQueue;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
|
||||
/** Observes server attribution only; never alters damage, cancellation, drops or death state. */
|
||||
final class BountyDeathListener implements Listener {
|
||||
private final java.util.function.Consumer<Death> capture;
|
||||
private final Clock clock;
|
||||
private final ReferenceQueue<PlayerDeathEvent> collected = new ReferenceQueue<>();
|
||||
private final Map<EventIdentity, Death> deaths = new HashMap<>();
|
||||
record Death(UUID id, Instant at, UUID target, UUID killer) {
|
||||
void replay(QuestActions actions) { actions.death(id, target, killer, at); }
|
||||
}
|
||||
|
||||
BountyDeathListener(QuestActions actions, Clock clock) {
|
||||
this(death -> death.replay(actions), clock);
|
||||
Objects.requireNonNull(actions);
|
||||
}
|
||||
|
||||
BountyDeathListener(java.util.function.Consumer<Death> capture, Clock clock) {
|
||||
this.capture = Objects.requireNonNull(capture);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onDeath(PlayerDeathEvent event) {
|
||||
if (event.isCancelled()) { return; }
|
||||
for (java.lang.ref.Reference<? extends PlayerDeathEvent> stale;
|
||||
(stale = collected.poll()) != null;) {
|
||||
deaths.remove(stale);
|
||||
}
|
||||
Death death = deaths.computeIfAbsent(new EventIdentity(event, collected), ignored -> {
|
||||
Player target = event.getEntity();
|
||||
Player killer = target.getKiller(); // Includes server-credited projectiles and indirect kills.
|
||||
return new Death(UUID.randomUUID(), clock.instant(), target.getUniqueId(),
|
||||
killer == null ? null : killer.getUniqueId());
|
||||
});
|
||||
if (death.killer() == null || death.target().equals(death.killer())) { return; }
|
||||
capture.accept(death);
|
||||
}
|
||||
|
||||
private static final class EventIdentity extends WeakReference<PlayerDeathEvent> {
|
||||
private final int hash;
|
||||
EventIdentity(PlayerDeathEvent event, ReferenceQueue<PlayerDeathEvent> queue) {
|
||||
super(event, queue);
|
||||
hash = System.identityHashCode(event);
|
||||
}
|
||||
@Override public int hashCode() { return hash; }
|
||||
@Override public boolean equals(Object other) {
|
||||
return this == other || other instanceof EventIdentity identity
|
||||
&& get() != null && get() == identity.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.OfflinePlayer;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.profile.PlayerProfile;
|
||||
|
||||
/** Called on main; the profile API owns network I/O, and all completions return to main. */
|
||||
final class BountyTargetResolver {
|
||||
record Target(UUID id, String name) { }
|
||||
private final Server server;
|
||||
private final Executor main;
|
||||
|
||||
BountyTargetResolver(Server server, Executor main) {
|
||||
this.server = Objects.requireNonNull(server);
|
||||
this.main = Objects.requireNonNull(main);
|
||||
}
|
||||
|
||||
void resolve(String input, Consumer<Target> success, Consumer<String> failure) {
|
||||
String text = input == null ? "" : input.trim();
|
||||
UUID id = null;
|
||||
try { id = UUID.fromString(text); }
|
||||
catch (IllegalArgumentException ignored) { /* A name may be supplied instead. */ }
|
||||
if (id == null && !text.matches("[A-Za-z0-9_]{1,16}")) {
|
||||
failure.accept("Enter a Minecraft player name or UUID.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Player online = id == null ? server.getPlayerExact(text) : server.getPlayer(id);
|
||||
if (online != null) {
|
||||
success.accept(new Target(online.getUniqueId(), online.getName()));
|
||||
return;
|
||||
}
|
||||
if (id == null) {
|
||||
OfflinePlayer cached = server.getOfflinePlayerIfCached(text);
|
||||
if (cached != null && cached.getName() != null) {
|
||||
success.accept(new Target(cached.getUniqueId(), cached.getName()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
PlayerProfile profile = id == null ? server.createPlayerProfile(text) : server.createPlayerProfile(id);
|
||||
profile.update().whenComplete((updated, error) -> {
|
||||
try {
|
||||
main.execute(() -> {
|
||||
if (error != null || updated == null || updated.getUniqueId() == null
|
||||
|| updated.getName() == null || updated.getName().isBlank()) {
|
||||
failure.accept("That target could not be resolved. Check the name or UUID and try again.");
|
||||
} else {
|
||||
success.accept(new Target(updated.getUniqueId(), updated.getName()));
|
||||
}
|
||||
});
|
||||
} catch (RejectedExecutionException ignored) {
|
||||
// Plugin stopped. No reward has been removed and no Bukkit callback is safe.
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException exception) {
|
||||
failure.accept("That target could not be resolved. Please try again.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
|
||||
final class BukkitIssuerNotifier implements IssuerNotifier, Listener {
|
||||
private final QuestService quests;
|
||||
private final Server server;
|
||||
private final Logger logger;
|
||||
private final Clock clock;
|
||||
|
||||
BukkitIssuerNotifier(QuestService quests, Server server, Logger logger) {
|
||||
this(quests, server, logger, Clock.systemUTC());
|
||||
}
|
||||
|
||||
BukkitIssuerNotifier(QuestService quests, Server server, Logger logger, Clock clock) {
|
||||
this.quests = Objects.requireNonNull(quests, "quests");
|
||||
this.server = Objects.requireNonNull(server, "server");
|
||||
this.logger = Objects.requireNonNull(logger, "logger");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void notifyIfOnline(UUID issuerId) {
|
||||
Player player = server.getPlayer(issuerId);
|
||||
if (player != null && player.isOnline()) {
|
||||
deliver(player);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onPlayerJoin(PlayerJoinEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
int count = quests.activeQuests(clock.instant()).size();
|
||||
String summary = switch (count) {
|
||||
case 0 -> "No quests are currently available.";
|
||||
case 1 -> "1 quest is currently available.";
|
||||
default -> count + " quests are currently available.";
|
||||
};
|
||||
player.sendMessage(summary + " Visit the quest board to accept or create a quest.");
|
||||
if (!quests.claimsFor(player.getUniqueId()).isEmpty()) {
|
||||
player.sendMessage(
|
||||
"You have items waiting to be claimed. Visit the quest board to collect them."
|
||||
);
|
||||
}
|
||||
deliver(player);
|
||||
}
|
||||
|
||||
void deliver(Player player) {
|
||||
for (IssuerNotification notification
|
||||
: quests.pendingNotifications(player.getUniqueId())) {
|
||||
player.sendMessage(notification.message());
|
||||
try {
|
||||
quests.markNotificationDelivered(notification.id());
|
||||
} catch (IOException exception) {
|
||||
logger.log(
|
||||
Level.WARNING,
|
||||
"Could not persist delivery of issuer notification " + notification.id()
|
||||
+ "; it will be retried",
|
||||
exception
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -6,11 +6,11 @@ import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import org.bukkit.Material;
|
||||
|
||||
final class BukkitBlockMaterialCatalog implements BlockMaterialCatalog {
|
||||
final class BukkitItemMaterialCatalog implements ItemMaterialCatalog {
|
||||
@Override
|
||||
public Optional<String> normalizeBlock(String input) {
|
||||
public Optional<String> normalizeItem(String input) {
|
||||
Material material = Material.matchMaterial(input == null ? "" : input);
|
||||
if (material == null || material.isAir() || !material.isBlock()) {
|
||||
if (material == null || material.isAir() || !material.isItem()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(material.name());
|
||||
@@ -20,7 +20,7 @@ final class BukkitBlockMaterialCatalog implements BlockMaterialCatalog {
|
||||
public List<String> suggest(String prefix) {
|
||||
String normalizedPrefix = prefix.toUpperCase(Locale.ROOT);
|
||||
return Arrays.stream(Material.values())
|
||||
.filter(material -> material.isBlock() && !material.isAir())
|
||||
.filter(material -> material.isItem() && !material.isAir())
|
||||
.map(Material::name)
|
||||
.filter(name -> name.startsWith(normalizedPrefix))
|
||||
.sorted()
|
||||
@@ -0,0 +1,137 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.BlockState;
|
||||
import org.bukkit.block.Sign;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.block.data.type.WallSign;
|
||||
import org.bukkit.block.sign.Side;
|
||||
import org.bukkit.block.sign.SignSide;
|
||||
|
||||
final class BukkitPhysicalBoardWorld implements PhysicalBoardWorld {
|
||||
private static final Component DECORATIVE_TEXT = Component.text("xxxxxxxx")
|
||||
.color(NamedTextColor.DARK_GREEN)
|
||||
.decorate(TextDecoration.OBFUSCATED);
|
||||
private static final List<Component> TITLE_TEXT = List.of(
|
||||
Component.text("Quest"), Component.text("Board"), Component.empty(), Component.empty()
|
||||
);
|
||||
private static final List<Component> INSTRUCTION_TEXT = List.of(
|
||||
Component.text("Right-click"), Component.text("a sign below"),
|
||||
Component.text("to browse"), Component.text("quests")
|
||||
);
|
||||
private final World world;
|
||||
|
||||
BukkitPhysicalBoardWorld(World world) {
|
||||
this.world = Objects.requireNonNull(world, "world");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEmpty(BoardId location) {
|
||||
return block(location).getType().isAir();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object snapshot(BoardId location) {
|
||||
return block(location).getState();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void place(PhysicalBoardPlan.Placement placement, BoardFacing facing) {
|
||||
Block block = block(placement.location());
|
||||
Material material = Objects.requireNonNull(
|
||||
Material.matchMaterial(placement.material()),
|
||||
"Unknown physical board material " + placement.material()
|
||||
);
|
||||
block.setType(material, false);
|
||||
if (!placement.sign()) {
|
||||
return;
|
||||
}
|
||||
|
||||
BlockData data = block.getBlockData();
|
||||
if (!(data instanceof WallSign wallSign)) {
|
||||
throw new IllegalStateException("Oak wall sign did not create wall-sign data");
|
||||
}
|
||||
wallSign.setFacing(facing.blockFace());
|
||||
block.setBlockData(wallSign, false);
|
||||
BlockState state = block.getState();
|
||||
if (!(state instanceof Sign sign)) {
|
||||
throw new IllegalStateException("Oak wall sign did not create sign state");
|
||||
}
|
||||
List<Component> lines = signLines(placement.signKind());
|
||||
SignSide front = sign.getSide(Side.FRONT);
|
||||
for (int line = 0; line < lines.size(); line++) {
|
||||
front.line(line, lines.get(line));
|
||||
}
|
||||
front.setGlowingText(placement.signKind() != PhysicalBoardSignKind.DECORATIVE);
|
||||
if (!sign.update(true, false)) {
|
||||
throw new IllegalStateException("Could not configure physical quest-board sign");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean matches(PhysicalBoardPlan.Placement placement, BoardFacing facing) {
|
||||
return matches(placement, facing,
|
||||
placement.signKind() != PhysicalBoardSignKind.DECORATIVE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean matches(
|
||||
PhysicalBoardPlan.Placement placement, BoardFacing facing, boolean glowingText
|
||||
) {
|
||||
Block block = block(placement.location());
|
||||
Material material = Material.matchMaterial(placement.material());
|
||||
if (material == null || block.getType() != material) {
|
||||
return false;
|
||||
}
|
||||
if (!placement.sign()) {
|
||||
return true;
|
||||
}
|
||||
if (!(block.getBlockData() instanceof WallSign wallSign)
|
||||
|| wallSign.getFacing() != facing.blockFace()
|
||||
|| !(block.getState() instanceof Sign sign)) {
|
||||
return false;
|
||||
}
|
||||
SignSide front = sign.getSide(Side.FRONT);
|
||||
if (front.isGlowingText() != glowingText) {
|
||||
return false;
|
||||
}
|
||||
List<Component> expected = signLines(placement.signKind());
|
||||
for (int line = 0; line < expected.size(); line++) {
|
||||
if (!expected.get(line).equals(front.line(line))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void restore(BoardId location, Object snapshot) {
|
||||
if (!(snapshot instanceof BlockState state) || !state.update(true, false)) {
|
||||
throw new IllegalStateException("Could not restore block at " + location);
|
||||
}
|
||||
}
|
||||
|
||||
static List<Component> signLines(PhysicalBoardSignKind kind) {
|
||||
return switch (Objects.requireNonNull(kind, "kind")) {
|
||||
case TITLE -> TITLE_TEXT;
|
||||
case INSTRUCTION -> INSTRUCTION_TEXT;
|
||||
case DECORATIVE -> List.of(
|
||||
DECORATIVE_TEXT, DECORATIVE_TEXT, DECORATIVE_TEXT, DECORATIVE_TEXT
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
private Block block(BoardId location) {
|
||||
if (!world.getUID().equals(location.worldId())) {
|
||||
throw new IllegalArgumentException("Physical board location belongs to another world");
|
||||
}
|
||||
return world.getBlockAt(location.x(), location.y(), location.z());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.bukkit.entity.Item;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
|
||||
final class BukkitQuestClaimInventory implements QuestClaimInventory {
|
||||
@Override
|
||||
public PreparedClaim prepare(List<EscrowItem> items) {
|
||||
List<ItemStack> stacks = items.stream().map(EscrowItem::toItemStack).toList();
|
||||
return player -> deliver(player, stacks);
|
||||
}
|
||||
|
||||
static Delivery deliver(Player player, List<ItemStack> stacks) {
|
||||
PlayerInventory inventory = player.getInventory();
|
||||
ItemStack[] snapshot = cloneContents(inventory.getStorageContents());
|
||||
List<Item> drops = new ArrayList<>();
|
||||
int overflow = 0;
|
||||
try {
|
||||
for (ItemStack stack : stacks) {
|
||||
Map<Integer, ItemStack> leftovers = inventory.addItem(stack.clone());
|
||||
for (ItemStack leftover : leftovers.values()) {
|
||||
Item drop = player.getWorld().dropItem(player.getLocation(), leftover.clone());
|
||||
drops.add(drop);
|
||||
drop.setOwner(player.getUniqueId());
|
||||
overflow += leftover.getAmount();
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
inventory.setStorageContents(cloneContents(snapshot));
|
||||
drops.forEach(Item::remove);
|
||||
throw exception;
|
||||
}
|
||||
int deliveredOverflow = overflow;
|
||||
return new Delivery() {
|
||||
private boolean rolledBack;
|
||||
|
||||
@Override
|
||||
public int overflowAmount() {
|
||||
return deliveredOverflow;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void rollback() {
|
||||
if (rolledBack) {
|
||||
return;
|
||||
}
|
||||
inventory.setStorageContents(cloneContents(snapshot));
|
||||
drops.forEach(Item::remove);
|
||||
rolledBack = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static ItemStack[] cloneContents(ItemStack[] contents) {
|
||||
ItemStack[] copy = new ItemStack[contents.length];
|
||||
for (int index = 0; index < contents.length; index++) {
|
||||
copy[index] = contents[index] == null ? null : contents[index].clone();
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Item;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
|
||||
final class BukkitQuestCompletionInventory implements QuestCompletionInventory {
|
||||
@Override
|
||||
public RemovedDelivery remove(Player player, String materialName, int amount) {
|
||||
Objects.requireNonNull(player, "player");
|
||||
Material material = Objects.requireNonNull(Material.matchMaterial(materialName), "material");
|
||||
PlayerInventory inventory = player.getInventory();
|
||||
ItemStack[] contents = inventory.getStorageContents();
|
||||
int available = 0;
|
||||
for (ItemStack stack : contents) {
|
||||
if (stack != null && stack.getType() == material) {
|
||||
available += stack.getAmount();
|
||||
}
|
||||
}
|
||||
if (available < amount) {
|
||||
throw new IllegalArgumentException(
|
||||
"You need exactly " + amount + " " + material.name()
|
||||
+ " to complete this quest (you have " + available + ")."
|
||||
);
|
||||
}
|
||||
|
||||
Map<Integer, ItemStack> snapshots = new LinkedHashMap<>();
|
||||
List<EscrowItem> removedItems = new ArrayList<>();
|
||||
int remaining = amount;
|
||||
for (int slot = 0; slot < contents.length && remaining > 0; slot++) {
|
||||
ItemStack stack = contents[slot];
|
||||
if (stack == null || stack.getType() != material) {
|
||||
continue;
|
||||
}
|
||||
snapshots.put(slot, stack.clone());
|
||||
int removedAmount = Math.min(stack.getAmount(), remaining);
|
||||
ItemStack removed = stack.clone();
|
||||
removed.setAmount(removedAmount);
|
||||
removedItems.add(EscrowItem.fromItemStack(removed));
|
||||
if (removedAmount == stack.getAmount()) {
|
||||
inventory.clear(slot);
|
||||
} else {
|
||||
ItemStack reduced = stack.clone();
|
||||
reduced.setAmount(stack.getAmount() - removedAmount);
|
||||
inventory.setItem(slot, reduced);
|
||||
}
|
||||
remaining -= removedAmount;
|
||||
}
|
||||
|
||||
return new RemovedDelivery() {
|
||||
private boolean rolledBack;
|
||||
|
||||
@Override
|
||||
public List<EscrowItem> items() {
|
||||
return List.copyOf(removedItems);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void rollback() {
|
||||
if (rolledBack) {
|
||||
return;
|
||||
}
|
||||
snapshots.forEach(inventory::setItem);
|
||||
rolledBack = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public PreparedReward prepare(List<EscrowItem> reward) {
|
||||
List<ItemStack> stacks = reward.stream().map(EscrowItem::toItemStack).toList();
|
||||
return player -> {
|
||||
int overflow = 0;
|
||||
for (ItemStack stack : stacks) {
|
||||
Map<Integer, ItemStack> leftovers = player.getInventory().addItem(stack.clone());
|
||||
for (ItemStack leftover : leftovers.values()) {
|
||||
Item dropped = player.getWorld().dropItem(player.getLocation(), leftover);
|
||||
dropped.setOwner(player.getUniqueId());
|
||||
overflow += leftover.getAmount();
|
||||
}
|
||||
}
|
||||
return overflow;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
|
||||
/** Returns removed items, not stale snapshots, if the inventory changed while I/O ran. */
|
||||
final class BukkitQuestInputInventory implements QuestInputInventory {
|
||||
private final QuestClaimInventory claims;
|
||||
|
||||
BukkitQuestInputInventory(QuestClaimInventory claims) {
|
||||
this.claims = Objects.requireNonNull(claims);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Reservation removeReward(Player player) {
|
||||
PlayerInventory inventory = player.getInventory();
|
||||
ItemStack held = inventory.getItemInMainHand();
|
||||
if (held == null || isAir(held.getType()) || held.getAmount() <= 0) {
|
||||
throw new IllegalArgumentException("Hold the reward stack in your main hand");
|
||||
}
|
||||
int slot = inventory.getHeldItemSlot();
|
||||
ItemStack original = held.clone();
|
||||
List<EscrowItem> items = List.of(EscrowItem.fromItemStack(original));
|
||||
Map<Integer, ItemStack> before = Map.of(slot, original);
|
||||
Map<Integer, ItemStack> after = new LinkedHashMap<>();
|
||||
after.put(slot, null);
|
||||
apply(inventory, before, after);
|
||||
return reservation(before, after, items);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Reservation removeDelivery(Player player, String materialName, int amount) {
|
||||
Material material = Material.matchMaterial(materialName);
|
||||
if (material == null || isAir(material) || amount <= 0) {
|
||||
throw new IllegalArgumentException("Invalid requested delivery");
|
||||
}
|
||||
PlayerInventory inventory = player.getInventory();
|
||||
ItemStack[] contents = inventory.getStorageContents();
|
||||
Map<Integer, ItemStack> before = new LinkedHashMap<>();
|
||||
Map<Integer, ItemStack> after = new LinkedHashMap<>();
|
||||
List<EscrowItem> items = new ArrayList<>();
|
||||
int remaining = amount;
|
||||
for (int slot = 0; slot < contents.length && remaining > 0; slot++) {
|
||||
ItemStack stack = contents[slot];
|
||||
if (stack == null || stack.getType() != material) {
|
||||
continue;
|
||||
}
|
||||
int take = Math.min(remaining, stack.getAmount());
|
||||
if (take <= 0) {
|
||||
continue;
|
||||
}
|
||||
before.put(slot, stack.clone());
|
||||
ItemStack removed = stack.clone();
|
||||
removed.setAmount(take);
|
||||
items.add(EscrowItem.fromItemStack(removed));
|
||||
ItemStack reduced = null;
|
||||
if (take < stack.getAmount()) {
|
||||
reduced = stack.clone();
|
||||
reduced.setAmount(stack.getAmount() - take);
|
||||
}
|
||||
after.put(slot, reduced);
|
||||
remaining -= take;
|
||||
}
|
||||
if (remaining != 0) {
|
||||
throw new IllegalArgumentException("You need exactly " + amount + " " + materialName
|
||||
+ " to complete this quest (you have " + (amount - remaining) + ").");
|
||||
}
|
||||
// Everything, including serialization, is validated before touching inventory.
|
||||
apply(inventory, before, after);
|
||||
return reservation(before, after, List.copyOf(items));
|
||||
}
|
||||
|
||||
private Reservation reservation(Map<Integer, ItemStack> before, Map<Integer, ItemStack> after,
|
||||
List<EscrowItem> items) {
|
||||
return new Reservation() {
|
||||
private boolean returned;
|
||||
@Override public List<EscrowItem> items() { return items; }
|
||||
@Override public void returnTo(Player player) {
|
||||
if (returned) {
|
||||
return;
|
||||
}
|
||||
if (!player.isOnline() || player.isDead()) {
|
||||
throw new IllegalStateException("Input return requires an online, living player");
|
||||
}
|
||||
PlayerInventory inventory = player.getInventory();
|
||||
boolean unchanged = after.entrySet().stream().allMatch(entry ->
|
||||
sameStack(inventory.getItem(entry.getKey()), entry.getValue()));
|
||||
if (unchanged) {
|
||||
// Compare touched slots only; unrelated slots are never overwritten.
|
||||
apply(inventory, after, before);
|
||||
} else {
|
||||
// Existing claim adapter handles overflow and synchronous partial-failure rollback.
|
||||
claims.prepare(items).deliver(player);
|
||||
}
|
||||
returned = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean isAir(Material material) {
|
||||
return material == Material.AIR || material == Material.CAVE_AIR || material == Material.VOID_AIR;
|
||||
}
|
||||
|
||||
private static boolean sameStack(ItemStack left, ItemStack right) {
|
||||
boolean leftEmpty = left == null || isAir(left.getType());
|
||||
boolean rightEmpty = right == null || isAir(right.getType());
|
||||
return leftEmpty && rightEmpty || !leftEmpty && !rightEmpty && left.equals(right);
|
||||
}
|
||||
|
||||
private static void apply(PlayerInventory inventory, Map<Integer, ItemStack> before,
|
||||
Map<Integer, ItemStack> after) {
|
||||
try {
|
||||
after.forEach((slot, stack) -> inventory.setItem(slot, stack == null ? null : stack.clone()));
|
||||
} catch (RuntimeException exception) {
|
||||
// This snapshot is from the SAME callback, not from before an asynchronous yield.
|
||||
before.forEach((slot, stack) -> inventory.setItem(slot, stack == null ? null : stack.clone()));
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
record ClaimCollectionResult(int claimsCollected, int overflowItems) {
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Durable delivery journal. RESERVED means delivery may have happened; after a restart it
|
||||
* must be reconciled by an operator, never automatically replayed. Inventory and YAML are
|
||||
* not one crash-atomic transaction. PENDING standalone entries are safe to reserve/deliver.
|
||||
*/
|
||||
record ClaimTransfer(UUID id, UUID ownerId, UUID claimId, List<EscrowItem> items, Status status) {
|
||||
enum Status { PENDING, RESERVED }
|
||||
|
||||
ClaimTransfer {
|
||||
Objects.requireNonNull(id, "id");
|
||||
Objects.requireNonNull(ownerId, "ownerId");
|
||||
items = List.copyOf(items);
|
||||
if (items.isEmpty()) {
|
||||
throw new IllegalArgumentException("Transfer items must not be empty");
|
||||
}
|
||||
Objects.requireNonNull(status, "status");
|
||||
}
|
||||
|
||||
ClaimTransfer reserved() {
|
||||
return new ClaimTransfer(id, ownerId, claimId, items, Status.RESERVED);
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,17 @@ package games.dmg.spigotquestboard;
|
||||
|
||||
import java.util.Base64;
|
||||
import java.util.Objects;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
|
||||
record EscrowItem(String material, int amount, String serializedItem, String customName) {
|
||||
EscrowItem(String material, int amount, String serializedItem) {
|
||||
this(material, amount, serializedItem, null);
|
||||
}
|
||||
|
||||
record EscrowItem(String material, int amount, String serializedItem) {
|
||||
EscrowItem {
|
||||
Objects.requireNonNull(material, "material");
|
||||
material = material.trim().toUpperCase(java.util.Locale.ROOT);
|
||||
@@ -18,24 +25,43 @@ record EscrowItem(String material, int amount, String serializedItem) {
|
||||
if (serializedItem != null && serializedItem.isBlank()) {
|
||||
throw new IllegalArgumentException("Serialized item must not be blank");
|
||||
}
|
||||
customName = normalizeReadableName(customName);
|
||||
}
|
||||
|
||||
static EscrowItem fromItemStack(ItemStack stack) {
|
||||
Objects.requireNonNull(stack, "stack");
|
||||
if (stack.getType().isAir() || stack.getAmount() <= 0) {
|
||||
if (isAir(stack.getType()) || stack.getAmount() <= 0) {
|
||||
throw new IllegalArgumentException("Reward stack must not be empty");
|
||||
}
|
||||
ItemStack snapshot = stack.clone();
|
||||
return new EscrowItem(
|
||||
snapshot.getType().name(), snapshot.getAmount(),
|
||||
Base64.getEncoder().encodeToString(snapshot.serializeAsBytes())
|
||||
Base64.getEncoder().encodeToString(snapshot.serializeAsBytes()),
|
||||
readableCustomName(snapshot)
|
||||
);
|
||||
}
|
||||
|
||||
EscrowItem inferCustomName() {
|
||||
if (customName != null || serializedItem == null) {
|
||||
return this;
|
||||
}
|
||||
try {
|
||||
String inferredName = readableCustomName(deserializeExactItem());
|
||||
return inferredName == null
|
||||
? this : new EscrowItem(material, amount, serializedItem, inferredName);
|
||||
} catch (RuntimeException | LinkageError exception) {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
ItemStack toItemStack() {
|
||||
if (serializedItem == null) {
|
||||
return new ItemStack(Objects.requireNonNull(Material.matchMaterial(material)), amount);
|
||||
}
|
||||
return deserializeExactItem();
|
||||
}
|
||||
|
||||
private ItemStack deserializeExactItem() {
|
||||
final ItemStack stack;
|
||||
try {
|
||||
stack = ItemStack.deserializeBytes(Base64.getDecoder().decode(serializedItem));
|
||||
@@ -47,4 +73,34 @@ record EscrowItem(String material, int amount, String serializedItem) {
|
||||
}
|
||||
return stack;
|
||||
}
|
||||
|
||||
private static boolean isAir(Material material) {
|
||||
return material == Material.AIR || material == Material.CAVE_AIR
|
||||
|| material == Material.VOID_AIR;
|
||||
}
|
||||
|
||||
private static String readableCustomName(ItemStack stack) {
|
||||
if (!stack.hasItemMeta()) {
|
||||
return null;
|
||||
}
|
||||
ItemMeta metadata = stack.getItemMeta();
|
||||
if (!metadata.hasCustomName()) {
|
||||
return null;
|
||||
}
|
||||
Component name = metadata.customName();
|
||||
if (name == null) {
|
||||
return null;
|
||||
}
|
||||
return normalizeReadableName(
|
||||
PlainTextComponentSerializer.plainText().serialize(name)
|
||||
);
|
||||
}
|
||||
|
||||
private static String normalizeReadableName(String name) {
|
||||
if (name == null) {
|
||||
return null;
|
||||
}
|
||||
String normalized = name.replaceAll("[\\p{Cntrl}\\s]+", " ").trim();
|
||||
return normalized.isEmpty() ? null : normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
record IssuerNotification(
|
||||
UUID id,
|
||||
UUID questId,
|
||||
UUID recipientId,
|
||||
String message,
|
||||
Instant createdAt
|
||||
) {
|
||||
IssuerNotification {
|
||||
Objects.requireNonNull(id, "id");
|
||||
Objects.requireNonNull(questId, "questId");
|
||||
Objects.requireNonNull(recipientId, "recipientId");
|
||||
if (Objects.requireNonNull(message, "message").isBlank()) {
|
||||
throw new IllegalArgumentException("Notification message must not be blank");
|
||||
}
|
||||
Objects.requireNonNull(createdAt, "createdAt");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
interface IssuerNotifier {
|
||||
void notifyIfOnline(UUID issuerId);
|
||||
}
|
||||
+2
-2
@@ -3,7 +3,7 @@ package games.dmg.spigotquestboard;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
interface BlockMaterialCatalog {
|
||||
Optional<String> normalizeBlock(String input);
|
||||
interface ItemMaterialCatalog {
|
||||
Optional<String> normalizeItem(String input);
|
||||
List<String> suggest(String prefix);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
enum PhysicalBoardCreationResult {
|
||||
CREATED,
|
||||
OBSTRUCTED,
|
||||
ALREADY_REGISTERED
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import org.bukkit.block.Block;
|
||||
|
||||
final class PhysicalBoardCreator {
|
||||
interface WorldFactory {
|
||||
PhysicalBoardWorld open(Block anchor);
|
||||
}
|
||||
|
||||
private final BoardRegistry registry;
|
||||
private final WorldFactory worlds;
|
||||
|
||||
PhysicalBoardCreator(BoardRegistry registry, WorldFactory worlds) {
|
||||
this.registry = Objects.requireNonNull(registry, "registry");
|
||||
this.worlds = Objects.requireNonNull(worlds, "worlds");
|
||||
}
|
||||
|
||||
PhysicalBoardCreationResult create(Block anchorBlock, BoardFacing facing) throws IOException {
|
||||
Objects.requireNonNull(anchorBlock, "anchorBlock");
|
||||
BoardId anchor = BoardId.from(anchorBlock);
|
||||
String worldName = anchorBlock.getWorld().getName();
|
||||
PhysicalBoardPlan plan = PhysicalBoardPlan.create(anchor, facing);
|
||||
PhysicalBoardWorld world = worlds.open(anchorBlock);
|
||||
|
||||
for (PhysicalBoardPlan.Placement placement : plan.placements()) {
|
||||
if (!world.isEmpty(placement.location())) {
|
||||
return PhysicalBoardCreationResult.OBSTRUCTED;
|
||||
}
|
||||
}
|
||||
for (BoardId location : plan.interactionLocations()) {
|
||||
if (registry.contains(location)) {
|
||||
return PhysicalBoardCreationResult.ALREADY_REGISTERED;
|
||||
}
|
||||
}
|
||||
|
||||
Map<BoardId, Object> snapshots = new LinkedHashMap<>();
|
||||
try {
|
||||
for (PhysicalBoardPlan.Placement placement : plan.placements()) {
|
||||
snapshots.put(placement.location(), world.snapshot(placement.location()));
|
||||
}
|
||||
for (PhysicalBoardPlan.Placement placement : plan.placements()) {
|
||||
world.place(placement, facing);
|
||||
}
|
||||
List<RegisteredBoard> registrations = plan.interactionLocations().stream()
|
||||
.map(location -> new RegisteredBoard(location, worldName))
|
||||
.toList();
|
||||
BoardRegistrationResult result = registry.registerAll(registrations);
|
||||
if (result != BoardRegistrationResult.CREATED) {
|
||||
rollback(world, snapshots, null);
|
||||
return PhysicalBoardCreationResult.ALREADY_REGISTERED;
|
||||
}
|
||||
return PhysicalBoardCreationResult.CREATED;
|
||||
} catch (RuntimeException | IOException exception) {
|
||||
rollback(world, snapshots, exception);
|
||||
if (exception instanceof IOException ioException) {
|
||||
throw ioException;
|
||||
}
|
||||
throw new IOException("Could not generate physical quest board", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void rollback(
|
||||
PhysicalBoardWorld world, Map<BoardId, Object> snapshots, Throwable failure
|
||||
) throws IOException {
|
||||
RuntimeException rollbackFailure = null;
|
||||
List<Map.Entry<BoardId, Object>> entries = new ArrayList<>(snapshots.entrySet());
|
||||
for (int index = entries.size() - 1; index >= 0; index--) {
|
||||
Map.Entry<BoardId, Object> entry = entries.get(index);
|
||||
try {
|
||||
world.restore(entry.getKey(), entry.getValue());
|
||||
} catch (RuntimeException exception) {
|
||||
if (rollbackFailure == null) {
|
||||
rollbackFailure = exception;
|
||||
} else {
|
||||
rollbackFailure.addSuppressed(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rollbackFailure != null) {
|
||||
if (failure != null) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
} else {
|
||||
throw new IOException("Could not fully restore physical quest board", rollbackFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
record PhysicalBoardPlan(
|
||||
BoardFacing facing,
|
||||
List<Placement> placements,
|
||||
Set<BoardId> interactionLocations
|
||||
) {
|
||||
PhysicalBoardPlan {
|
||||
Objects.requireNonNull(facing, "facing");
|
||||
placements = List.copyOf(Objects.requireNonNull(placements, "placements"));
|
||||
interactionLocations = Set.copyOf(Objects.requireNonNull(
|
||||
interactionLocations, "interactionLocations"
|
||||
));
|
||||
}
|
||||
|
||||
static PhysicalBoardPlan create(BoardId anchor, BoardFacing facing) {
|
||||
Objects.requireNonNull(anchor, "anchor");
|
||||
Objects.requireNonNull(facing, "facing");
|
||||
List<Placement> placements = new ArrayList<>(25);
|
||||
Set<BoardId> interactions = new LinkedHashSet<>(17);
|
||||
|
||||
for (int height = 1; height <= 4; height++) {
|
||||
for (int lateral = -2; lateral <= 2; lateral++) {
|
||||
BoardId location = offset(anchor, facing, lateral, height, 0);
|
||||
String material = Math.abs(lateral) == 2 ? "OAK_LOG" : "OAK_PLANKS";
|
||||
placements.add(new Placement(location, material));
|
||||
if ("OAK_PLANKS".equals(material)) {
|
||||
interactions.add(location);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int lateral = -1; lateral <= 1; lateral++) {
|
||||
BoardId location = offset(anchor, facing, lateral, 2, 1);
|
||||
placements.add(new Placement(
|
||||
location, "OAK_WALL_SIGN", PhysicalBoardSignKind.DECORATIVE
|
||||
));
|
||||
interactions.add(location);
|
||||
}
|
||||
addSign(
|
||||
placements, interactions, offset(anchor, facing, 0, 3, 1),
|
||||
PhysicalBoardSignKind.INSTRUCTION
|
||||
);
|
||||
addSign(
|
||||
placements, interactions, offset(anchor, facing, 0, 4, 1),
|
||||
PhysicalBoardSignKind.TITLE
|
||||
);
|
||||
return new PhysicalBoardPlan(facing, placements, interactions);
|
||||
}
|
||||
|
||||
private static void addSign(
|
||||
List<Placement> placements,
|
||||
Set<BoardId> interactions,
|
||||
BoardId location,
|
||||
PhysicalBoardSignKind kind
|
||||
) {
|
||||
placements.add(new Placement(location, "OAK_WALL_SIGN", kind));
|
||||
interactions.add(location);
|
||||
}
|
||||
|
||||
private static BoardId offset(
|
||||
BoardId anchor, BoardFacing facing, int lateral, int vertical, int forward
|
||||
) {
|
||||
return new BoardId(
|
||||
anchor.worldId(),
|
||||
anchor.x() + lateral * facing.rightX() + forward * facing.x(),
|
||||
anchor.y() + vertical,
|
||||
anchor.z() + lateral * facing.rightZ() + forward * facing.z()
|
||||
);
|
||||
}
|
||||
|
||||
record Placement(
|
||||
BoardId location,
|
||||
String material,
|
||||
PhysicalBoardSignKind signKind
|
||||
) {
|
||||
Placement {
|
||||
Objects.requireNonNull(location, "location");
|
||||
Objects.requireNonNull(material, "material");
|
||||
if ("OAK_WALL_SIGN".equals(material) != (signKind != null)) {
|
||||
throw new IllegalArgumentException("Only wall signs require sign metadata");
|
||||
}
|
||||
}
|
||||
|
||||
Placement(BoardId location, String material) {
|
||||
this(
|
||||
location,
|
||||
material,
|
||||
"OAK_WALL_SIGN".equals(material) ? PhysicalBoardSignKind.DECORATIVE : null
|
||||
);
|
||||
}
|
||||
|
||||
boolean sign() {
|
||||
return signKind != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
enum PhysicalBoardSignKind {
|
||||
TITLE,
|
||||
INSTRUCTION,
|
||||
DECORATIVE
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
final class PhysicalBoardUpgrader {
|
||||
interface WorldFactory {
|
||||
PhysicalBoardWorld open(UUID worldId);
|
||||
}
|
||||
|
||||
private record Candidate(BoardId anchor, BoardFacing facing, String worldName) { }
|
||||
private record RelativeInteraction(int lateral, int height, int forward) { }
|
||||
|
||||
private static final List<RelativeInteraction> LEGACY_INTERACTIONS = legacyInteractions();
|
||||
|
||||
private final BoardRegistry registry;
|
||||
private final WorldFactory worlds;
|
||||
|
||||
PhysicalBoardUpgrader(BoardRegistry registry, WorldFactory worlds) {
|
||||
this.registry = Objects.requireNonNull(registry, "registry");
|
||||
this.worlds = Objects.requireNonNull(worlds, "worlds");
|
||||
}
|
||||
|
||||
int upgrade() throws IOException {
|
||||
int upgraded = 0;
|
||||
Map<BoardId, RegisteredBoard> registered = registry.registeredBoards();
|
||||
for (Candidate candidate : candidates(registered)) {
|
||||
boolean legacy = hasLegacyRegistrations(candidate, registered);
|
||||
boolean full = hasFullRegistrations(candidate, registered);
|
||||
if (!legacy && !full) {
|
||||
continue;
|
||||
}
|
||||
PhysicalBoardWorld world = worlds.open(candidate.anchor().worldId());
|
||||
if (world == null) {
|
||||
continue;
|
||||
}
|
||||
if (legacy && isLegacyBoard(candidate, world) && upgradeLegacy(candidate, world)) {
|
||||
upgraded++;
|
||||
registered = registry.registeredBoards();
|
||||
} else if (full && isNonGlowingFullBoard(candidate, world)) {
|
||||
refreshReadableSigns(candidate, world);
|
||||
upgraded++;
|
||||
}
|
||||
}
|
||||
return upgraded;
|
||||
}
|
||||
|
||||
private static boolean hasLegacyRegistrations(
|
||||
Candidate candidate, Map<BoardId, RegisteredBoard> registered
|
||||
) {
|
||||
PhysicalBoardPlan plan = PhysicalBoardPlan.create(candidate.anchor(), candidate.facing());
|
||||
for (BoardId location : legacyInteractionLocations(plan)) {
|
||||
RegisteredBoard board = registered.get(location);
|
||||
if (board == null || !candidate.worldName().equals(board.worldName())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return newSigns(plan).stream().noneMatch(sign ->
|
||||
registered.containsKey(sign.location())
|
||||
);
|
||||
}
|
||||
|
||||
private static boolean hasFullRegistrations(
|
||||
Candidate candidate, Map<BoardId, RegisteredBoard> registered
|
||||
) {
|
||||
PhysicalBoardPlan plan = PhysicalBoardPlan.create(candidate.anchor(), candidate.facing());
|
||||
for (BoardId location : plan.interactionLocations()) {
|
||||
RegisteredBoard board = registered.get(location);
|
||||
if (board == null || !candidate.worldName().equals(board.worldName())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean isLegacyBoard(Candidate candidate, PhysicalBoardWorld world) {
|
||||
PhysicalBoardPlan plan = PhysicalBoardPlan.create(candidate.anchor(), candidate.facing());
|
||||
for (PhysicalBoardPlan.Placement placement : legacyPlacements(plan)) {
|
||||
if (!world.matches(placement, candidate.facing())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (PhysicalBoardPlan.Placement placement : newSigns(plan)) {
|
||||
if (!world.isEmpty(placement.location())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean isNonGlowingFullBoard(Candidate candidate, PhysicalBoardWorld world) {
|
||||
PhysicalBoardPlan plan = PhysicalBoardPlan.create(candidate.anchor(), candidate.facing());
|
||||
for (PhysicalBoardPlan.Placement placement : plan.placements()) {
|
||||
boolean matches = placement.sign()
|
||||
? world.matches(placement, candidate.facing(), false)
|
||||
: world.matches(placement, candidate.facing());
|
||||
if (!matches) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean upgradeLegacy(Candidate candidate, PhysicalBoardWorld world) throws IOException {
|
||||
List<PhysicalBoardPlan.Placement> signs = readableSigns(
|
||||
PhysicalBoardPlan.create(candidate.anchor(), candidate.facing())
|
||||
);
|
||||
Map<BoardId, Object> snapshots = new LinkedHashMap<>();
|
||||
try {
|
||||
for (PhysicalBoardPlan.Placement sign : signs) {
|
||||
snapshots.put(sign.location(), world.snapshot(sign.location()));
|
||||
}
|
||||
for (PhysicalBoardPlan.Placement sign : signs) {
|
||||
world.place(sign, candidate.facing());
|
||||
}
|
||||
List<RegisteredBoard> additions = signs.stream()
|
||||
.map(sign -> new RegisteredBoard(sign.location(), candidate.worldName()))
|
||||
.toList();
|
||||
if (registry.registerAll(additions) != BoardRegistrationResult.CREATED) {
|
||||
rollback(world, snapshots, null);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (RuntimeException | IOException exception) {
|
||||
rollback(world, snapshots, exception);
|
||||
if (exception instanceof IOException ioException) {
|
||||
throw ioException;
|
||||
}
|
||||
throw new IOException("Could not upgrade physical quest board", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static Set<Candidate> candidates(Map<BoardId, RegisteredBoard> registered) {
|
||||
Set<Candidate> candidates = new LinkedHashSet<>();
|
||||
for (RegisteredBoard board : registered.values()) {
|
||||
for (BoardFacing facing : BoardFacing.values()) {
|
||||
for (RelativeInteraction relative : LEGACY_INTERACTIONS) {
|
||||
BoardId location = board.id();
|
||||
candidates.add(new Candidate(new BoardId(
|
||||
location.worldId(),
|
||||
location.x() - relative.lateral() * facing.rightX()
|
||||
- relative.forward() * facing.x(),
|
||||
location.y() - relative.height(),
|
||||
location.z() - relative.lateral() * facing.rightZ()
|
||||
- relative.forward() * facing.z()
|
||||
), facing, board.worldName()));
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private static List<RelativeInteraction> legacyInteractions() {
|
||||
List<RelativeInteraction> interactions = new ArrayList<>(15);
|
||||
for (int height = 1; height <= 4; height++) {
|
||||
for (int lateral = -1; lateral <= 1; lateral++) {
|
||||
interactions.add(new RelativeInteraction(lateral, height, 0));
|
||||
}
|
||||
}
|
||||
for (int lateral = -1; lateral <= 1; lateral++) {
|
||||
interactions.add(new RelativeInteraction(lateral, 2, 1));
|
||||
}
|
||||
return List.copyOf(interactions);
|
||||
}
|
||||
|
||||
private static List<PhysicalBoardPlan.Placement> legacyPlacements(PhysicalBoardPlan plan) {
|
||||
return plan.placements().stream().filter(placement ->
|
||||
!placement.sign() || placement.signKind() == PhysicalBoardSignKind.DECORATIVE
|
||||
).toList();
|
||||
}
|
||||
|
||||
private static Set<BoardId> legacyInteractionLocations(PhysicalBoardPlan plan) {
|
||||
Set<BoardId> locations = new LinkedHashSet<>();
|
||||
for (PhysicalBoardPlan.Placement placement : legacyPlacements(plan)) {
|
||||
if ("OAK_PLANKS".equals(placement.material()) || placement.sign()) {
|
||||
locations.add(placement.location());
|
||||
}
|
||||
}
|
||||
return Set.copyOf(locations);
|
||||
}
|
||||
|
||||
private void refreshReadableSigns(Candidate candidate, PhysicalBoardWorld world)
|
||||
throws IOException {
|
||||
List<PhysicalBoardPlan.Placement> signs = readableSigns(
|
||||
PhysicalBoardPlan.create(candidate.anchor(), candidate.facing())
|
||||
);
|
||||
Map<BoardId, Object> snapshots = new LinkedHashMap<>();
|
||||
try {
|
||||
for (PhysicalBoardPlan.Placement sign : signs) {
|
||||
snapshots.put(sign.location(), world.snapshot(sign.location()));
|
||||
}
|
||||
for (PhysicalBoardPlan.Placement sign : signs) {
|
||||
world.place(sign, candidate.facing());
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
rollback(world, snapshots, exception);
|
||||
throw new IOException("Could not refresh physical quest-board signs", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<PhysicalBoardPlan.Placement> newSigns(PhysicalBoardPlan plan) {
|
||||
return readableSigns(plan);
|
||||
}
|
||||
|
||||
private static List<PhysicalBoardPlan.Placement> readableSigns(PhysicalBoardPlan plan) {
|
||||
return plan.placements().stream().filter(placement ->
|
||||
placement.signKind() == PhysicalBoardSignKind.TITLE
|
||||
|| placement.signKind() == PhysicalBoardSignKind.INSTRUCTION
|
||||
).toList();
|
||||
}
|
||||
|
||||
private static void rollback(
|
||||
PhysicalBoardWorld world, Map<BoardId, Object> snapshots, Throwable failure
|
||||
) throws IOException {
|
||||
RuntimeException rollbackFailure = null;
|
||||
List<Map.Entry<BoardId, Object>> entries = new ArrayList<>(snapshots.entrySet());
|
||||
for (int index = entries.size() - 1; index >= 0; index--) {
|
||||
Map.Entry<BoardId, Object> entry = entries.get(index);
|
||||
try {
|
||||
world.restore(entry.getKey(), entry.getValue());
|
||||
} catch (RuntimeException exception) {
|
||||
if (rollbackFailure == null) {
|
||||
rollbackFailure = exception;
|
||||
} else {
|
||||
rollbackFailure.addSuppressed(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rollbackFailure != null) {
|
||||
if (failure != null) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
} else {
|
||||
throw new IOException("Could not restore physical board upgrade", rollbackFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
interface PhysicalBoardWorld {
|
||||
boolean isEmpty(BoardId location);
|
||||
|
||||
Object snapshot(BoardId location);
|
||||
|
||||
void place(PhysicalBoardPlan.Placement placement, BoardFacing facing);
|
||||
|
||||
default boolean matches(PhysicalBoardPlan.Placement placement, BoardFacing facing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
default boolean matches(
|
||||
PhysicalBoardPlan.Placement placement, BoardFacing facing, boolean glowingText
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
void restore(BoardId location, Object snapshot);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Objects;
|
||||
|
||||
final class PlayerCommandSettings {
|
||||
private final PlayerCommandSettingsRepository repository;
|
||||
private boolean enabled;
|
||||
|
||||
PlayerCommandSettings(PlayerCommandSettingsRepository repository) throws IOException {
|
||||
this.repository = Objects.requireNonNull(repository, "repository");
|
||||
enabled = repository.loadEnabled();
|
||||
}
|
||||
|
||||
boolean enabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
void setEnabled(boolean enabled) throws IOException {
|
||||
repository.saveEnabled(enabled);
|
||||
this.enabled = enabled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
interface PlayerCommandSettingsRepository {
|
||||
boolean loadEnabled() throws IOException;
|
||||
void saveEnabled(boolean enabled) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Runtime mutation boundary. Player-bearing entry points must be called on the main thread. */
|
||||
interface QuestActions {
|
||||
void create(Player player, String material, int amount, Instant at);
|
||||
void postBounty(Player player, UUID targetId, String targetName, int kills, Instant at);
|
||||
void complete(Player player, String id, Instant at);
|
||||
void cancel(Player player, String id, Instant at);
|
||||
void cancelBounty(Player player, String id, Instant at);
|
||||
void collect(Player player);
|
||||
void death(UUID deathId, UUID targetId, UUID killerId, Instant at);
|
||||
void expire(Instant at);
|
||||
void joined(Player player);
|
||||
}
|
||||
@@ -1,19 +1,40 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.TabCompleter;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
final class QuestAdminCommand implements CommandExecutor {
|
||||
final class QuestAdminCommand implements CommandExecutor, TabCompleter {
|
||||
private static final String PERMISSION = "spigotquestboard.admin";
|
||||
private final BoardRegistry registry;
|
||||
private final PlayerCommandSettings playerCommands;
|
||||
private final PhysicalBoardCreator physicalBoards;
|
||||
|
||||
QuestAdminCommand(BoardRegistry registry) {
|
||||
QuestAdminCommand(BoardRegistry registry, PlayerCommandSettings playerCommands) {
|
||||
this(
|
||||
registry,
|
||||
playerCommands,
|
||||
new PhysicalBoardCreator(
|
||||
registry, anchor -> new BukkitPhysicalBoardWorld(anchor.getWorld())
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
QuestAdminCommand(
|
||||
BoardRegistry registry,
|
||||
PlayerCommandSettings playerCommands,
|
||||
PhysicalBoardCreator physicalBoards
|
||||
) {
|
||||
this.registry = Objects.requireNonNull(registry, "registry");
|
||||
this.playerCommands = Objects.requireNonNull(playerCommands, "playerCommands");
|
||||
this.physicalBoards = Objects.requireNonNull(physicalBoards, "physicalBoards");
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -24,18 +45,56 @@ final class QuestAdminCommand implements CommandExecutor {
|
||||
sender.sendMessage("You do not have permission to administer quest boards.");
|
||||
return true;
|
||||
}
|
||||
if (arguments.length != 1 || !"createboard".equalsIgnoreCase(arguments[0])) {
|
||||
sender.sendMessage("Usage: /questadmin createboard");
|
||||
if (arguments.length == 2 && "commands".equalsIgnoreCase(arguments[0])) {
|
||||
return updatePlayerCommands(sender, arguments[1]);
|
||||
}
|
||||
if (arguments.length == 1 && "createboard".equalsIgnoreCase(arguments[0])) {
|
||||
createBoard(sender);
|
||||
return true;
|
||||
}
|
||||
if (arguments.length == 2
|
||||
&& "createboard".equalsIgnoreCase(arguments[0])
|
||||
&& "physical".equalsIgnoreCase(arguments[1])) {
|
||||
createPhysicalBoard(sender);
|
||||
return true;
|
||||
}
|
||||
sender.sendMessage(
|
||||
"Usage: /questadmin createboard [physical] | /questadmin commands enable|disable"
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean updatePlayerCommands(CommandSender sender, String action) {
|
||||
final boolean enabled;
|
||||
if ("enable".equalsIgnoreCase(action)) {
|
||||
enabled = true;
|
||||
} else if ("disable".equalsIgnoreCase(action)) {
|
||||
enabled = false;
|
||||
} else {
|
||||
sender.sendMessage("Usage: /questadmin commands enable|disable");
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
playerCommands.setEnabled(enabled);
|
||||
sender.sendMessage("Player quest commands " + (enabled ? "enabled." : "disabled."));
|
||||
} catch (IOException exception) {
|
||||
sender.sendMessage(
|
||||
"Player command setting could not be saved. Player quest commands remain "
|
||||
+ (playerCommands.enabled() ? "enabled." : "disabled.")
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void createBoard(CommandSender sender) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage("A player must target the quest board block.");
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
Block target = player.getTargetBlockExact(5);
|
||||
if (target == null) {
|
||||
sender.sendMessage("Target a physical block within five blocks.");
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
BoardRegistrationResult result = registry.register(RegisteredBoard.from(target));
|
||||
@@ -45,6 +104,61 @@ final class QuestAdminCommand implements CommandExecutor {
|
||||
} catch (IOException exception) {
|
||||
sender.sendMessage("The quest board could not be saved. No board was created.");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void createPhysicalBoard(CommandSender sender) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage("A player must target the ground anchor block.");
|
||||
return;
|
||||
}
|
||||
Block target = player.getTargetBlockExact(5);
|
||||
if (target == null) {
|
||||
sender.sendMessage("Target a ground anchor within five blocks.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
PhysicalBoardCreationResult result = physicalBoards.create(
|
||||
target, BoardFacing.towardPlayer(player.getFacing())
|
||||
);
|
||||
switch (result) {
|
||||
case CREATED -> sender.sendMessage("Physical quest board created.");
|
||||
case OBSTRUCTED -> sender.sendMessage(
|
||||
"The physical quest board needs 23 empty placement blocks."
|
||||
);
|
||||
case ALREADY_REGISTERED -> sender.sendMessage(
|
||||
"A physical quest-board interaction location is already registered."
|
||||
);
|
||||
}
|
||||
} catch (IOException | IllegalArgumentException exception) {
|
||||
sender.sendMessage(
|
||||
"The physical quest board could not be created. All changed blocks were restored."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> onTabComplete(
|
||||
CommandSender sender, Command command, String alias, String[] arguments
|
||||
) {
|
||||
if (!sender.hasPermission(PERMISSION)) {
|
||||
return List.of();
|
||||
}
|
||||
if (arguments.length == 1) {
|
||||
return startsWith(List.of("createboard", "commands"), arguments[0]);
|
||||
}
|
||||
if (arguments.length == 2 && "commands".equalsIgnoreCase(arguments[0])) {
|
||||
return startsWith(List.of("enable", "disable"), arguments[1]);
|
||||
}
|
||||
if (arguments.length == 2 && "createboard".equalsIgnoreCase(arguments[0])) {
|
||||
return startsWith(List.of("physical"), arguments[1]);
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private static List<String> startsWith(List<String> candidates, String prefix) {
|
||||
String normalized = prefix.toLowerCase(Locale.ROOT);
|
||||
return candidates.stream()
|
||||
.filter(candidate -> candidate.startsWith(normalized))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** A server-independent description of the quest-board dialog hierarchy. */
|
||||
record QuestBoardDialogSpec(
|
||||
int maximumBodyWidth,
|
||||
Screen dashboard,
|
||||
Screen browse,
|
||||
Screen create,
|
||||
Screen createBounty,
|
||||
Screen claims,
|
||||
List<QuestEntry> questEntries
|
||||
) {
|
||||
QuestBoardDialogSpec {
|
||||
if (maximumBodyWidth <= 0) {
|
||||
throw new IllegalArgumentException("Maximum body width must be positive");
|
||||
}
|
||||
Objects.requireNonNull(dashboard, "dashboard");
|
||||
Objects.requireNonNull(browse, "browse");
|
||||
Objects.requireNonNull(create, "create");
|
||||
Objects.requireNonNull(claims, "claims");
|
||||
questEntries = List.copyOf(Objects.requireNonNull(questEntries, "questEntries"));
|
||||
}
|
||||
|
||||
List<String> dashboardActions() {
|
||||
return dashboard.actions().stream().map(Action::label).toList();
|
||||
}
|
||||
|
||||
record Screen(
|
||||
String title,
|
||||
String externalTitle,
|
||||
String message,
|
||||
List<Action> actions,
|
||||
List<Input> inputs,
|
||||
boolean hasBackAction
|
||||
) {
|
||||
Screen {
|
||||
Objects.requireNonNull(title, "title");
|
||||
Objects.requireNonNull(externalTitle, "externalTitle");
|
||||
Objects.requireNonNull(message, "message");
|
||||
actions = List.copyOf(Objects.requireNonNull(actions, "actions"));
|
||||
inputs = List.copyOf(Objects.requireNonNull(inputs, "inputs"));
|
||||
}
|
||||
}
|
||||
|
||||
record QuestEntry(
|
||||
String title,
|
||||
String message,
|
||||
List<Action> actions,
|
||||
boolean hasBackAction
|
||||
) {
|
||||
QuestEntry {
|
||||
Objects.requireNonNull(title, "title");
|
||||
Objects.requireNonNull(message, "message");
|
||||
actions = List.copyOf(Objects.requireNonNull(actions, "actions"));
|
||||
}
|
||||
}
|
||||
|
||||
record Action(String label, Route route, String questId) {
|
||||
Action {
|
||||
Objects.requireNonNull(label, "label");
|
||||
Objects.requireNonNull(route, "route");
|
||||
}
|
||||
}
|
||||
|
||||
record Input(String key, String label, String initial, int maximumLength) {
|
||||
Input {
|
||||
Objects.requireNonNull(key, "key");
|
||||
Objects.requireNonNull(label, "label");
|
||||
Objects.requireNonNull(initial, "initial");
|
||||
if (maximumLength <= 0) {
|
||||
throw new IllegalArgumentException("Maximum input length must be positive");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Route {
|
||||
OPEN_BROWSE,
|
||||
OPEN_CREATE,
|
||||
OPEN_CREATE_BOUNTY,
|
||||
OPEN_CLAIMS,
|
||||
SUBMIT_CREATE,
|
||||
SUBMIT_BOUNTY,
|
||||
COMPLETE,
|
||||
CANCEL,
|
||||
CANCEL_BOUNTY,
|
||||
COLLECT
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import io.papermc.paper.dialog.Dialog;
|
||||
import io.papermc.paper.registry.RegistryKey;
|
||||
import io.papermc.paper.registry.data.dialog.ActionButton;
|
||||
import io.papermc.paper.registry.data.dialog.DialogBase;
|
||||
import io.papermc.paper.registry.data.dialog.action.DialogAction;
|
||||
import io.papermc.paper.registry.data.dialog.body.DialogBody;
|
||||
import io.papermc.paper.registry.data.dialog.input.DialogInput;
|
||||
import io.papermc.paper.registry.data.dialog.type.DialogType;
|
||||
import io.papermc.paper.registry.set.RegistrySet;
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
@@ -17,59 +19,401 @@ import net.kyori.adventure.text.event.ClickCallback;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
private static final int DIALOG_WIDTH = 420;
|
||||
private static final ClickCallback.Options CALLBACK_OPTIONS = ClickCallback.Options.builder()
|
||||
.uses(1)
|
||||
.lifetime(Duration.ofMinutes(10))
|
||||
.build();
|
||||
|
||||
private final QuestCreationGateway creator;
|
||||
private final QuestBrowser browser;
|
||||
private final QuestCompletionGateway completer;
|
||||
private final QuestCancellationGateway canceller;
|
||||
private final QuestClaimGateway claimant;
|
||||
private final Clock clock;
|
||||
private QuestActions actions;
|
||||
private QuestService service;
|
||||
private BountyTargetResolver targets;
|
||||
private java.util.concurrent.Executor main = Runnable::run;
|
||||
private static final String BOUNTY_RULES =
|
||||
"The first hunter to reach the entire required kill count wins the exact reward. "
|
||||
+ "Progress is individual, not shared. The issuer cannot earn their own bounty. "
|
||||
+ "No partial reward is paid. Only the issuer may cancel, including after partial progress. "
|
||||
+ "Cancellation or expiry after 7 days returns unpaid escrow to the issuer's pending claims. "
|
||||
+ "Existing PvP rules and protections still apply.";
|
||||
|
||||
QuestBoardDialogUi(QuestCreationGateway creator, QuestBrowser browser, Clock clock) {
|
||||
this(creator, browser, null, null, null, clock);
|
||||
}
|
||||
|
||||
QuestBoardDialogUi(
|
||||
QuestCreationGateway creator,
|
||||
QuestBrowser browser,
|
||||
QuestCompletionGateway completer,
|
||||
Clock clock
|
||||
) {
|
||||
this(creator, browser, completer, null, null, clock);
|
||||
}
|
||||
|
||||
QuestBoardDialogUi(
|
||||
QuestCreationGateway creator,
|
||||
QuestBrowser browser,
|
||||
QuestCompletionGateway completer,
|
||||
QuestCancellationGateway canceller,
|
||||
Clock clock
|
||||
) {
|
||||
this(creator, browser, completer, canceller, null, clock);
|
||||
}
|
||||
|
||||
QuestBoardDialogUi(
|
||||
QuestCreationGateway creator,
|
||||
QuestBrowser browser,
|
||||
QuestCompletionGateway completer,
|
||||
QuestCancellationGateway canceller,
|
||||
QuestClaimGateway claimant,
|
||||
Clock clock
|
||||
) {
|
||||
this.creator = Objects.requireNonNull(creator, "creator");
|
||||
this.browser = Objects.requireNonNull(browser, "browser");
|
||||
this.completer = completer;
|
||||
this.canceller = canceller;
|
||||
this.claimant = claimant;
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
QuestBoardDialogUi(QuestActions actions, QuestService service, BountyTargetResolver targets,
|
||||
java.util.concurrent.Executor main, Clock clock) {
|
||||
this(new QuestCreationGateway() {
|
||||
@Override public Quest create(Player player, String material, int amount, java.time.Instant at) {
|
||||
throw new IllegalStateException("Runtime creation must use QuestActions");
|
||||
}
|
||||
@Override public List<String> suggestItemMaterials(String prefix) { return List.of(); }
|
||||
}, service, clock);
|
||||
this.actions = Objects.requireNonNull(actions);
|
||||
this.service = Objects.requireNonNull(service);
|
||||
this.targets = Objects.requireNonNull(targets);
|
||||
this.main = Objects.requireNonNull(main);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void open(Player player) {
|
||||
Objects.requireNonNull(player, "player");
|
||||
player.showDialog(renderDashboard(specification(player), player));
|
||||
}
|
||||
|
||||
QuestBoardDialogSpec specification(Player player) {
|
||||
Objects.requireNonNull(player, "player");
|
||||
java.time.Instant now = clock.instant();
|
||||
List<QuestBoardDialogSpec.QuestEntry> entries = new java.util.ArrayList<>(browser.activeQuests(now).stream()
|
||||
.map(quest -> questEntry(quest, player, now)).toList());
|
||||
if (service != null) {
|
||||
service.activeBounties(now).stream().map(bounty -> bountyEntry(bounty, player, now)).forEach(entries::add);
|
||||
}
|
||||
List<QuestBoardDialogSpec.Action> dashboardActions = new java.util.ArrayList<>(List.of(
|
||||
action("Browse quests", QuestBoardDialogSpec.Route.OPEN_BROWSE),
|
||||
action("Create quest", QuestBoardDialogSpec.Route.OPEN_CREATE)
|
||||
));
|
||||
if (actions != null) {
|
||||
dashboardActions.add(action("Create bounty", QuestBoardDialogSpec.Route.OPEN_CREATE_BOUNTY));
|
||||
}
|
||||
dashboardActions.add(action("Pending claims", QuestBoardDialogSpec.Route.OPEN_CLAIMS));
|
||||
QuestBoardDialogSpec.Screen dashboard = new QuestBoardDialogSpec.Screen(
|
||||
"Quest Board",
|
||||
"Quest Board",
|
||||
"Choose what you want to do.",
|
||||
dashboardActions,
|
||||
List.of(),
|
||||
false
|
||||
);
|
||||
QuestBoardDialogSpec.Screen browse = new QuestBoardDialogSpec.Screen(
|
||||
"Browse Quests",
|
||||
"Browse quests",
|
||||
entries.isEmpty()
|
||||
? "No active quests."
|
||||
: "Select a quest or bounty to view its objective, exact reward, issuer, and time remaining.",
|
||||
List.of(),
|
||||
List.of(),
|
||||
true
|
||||
);
|
||||
QuestBoardDialogSpec.Screen create = new QuestBoardDialogSpec.Screen(
|
||||
"Create Quest",
|
||||
"Create quest",
|
||||
"Hold the reward in your main hand. The entire exact stack, including all item "
|
||||
+ "metadata, is escrowed only if the quest saves successfully.",
|
||||
List.of(action("Create quest", QuestBoardDialogSpec.Route.SUBMIT_CREATE)),
|
||||
List.of(
|
||||
new QuestBoardDialogSpec.Input("requested_material", "Requested item", "", 64),
|
||||
new QuestBoardDialogSpec.Input("requested_quantity", "Quantity", "64", 10)
|
||||
),
|
||||
true
|
||||
);
|
||||
QuestBoardDialogSpec.Screen createBounty = actions == null ? null : new QuestBoardDialogSpec.Screen(
|
||||
"Create Bounty", "Create bounty",
|
||||
"Hold the exact reward stack in your main hand while the target is resolved. "
|
||||
+ "The entire stack, including custom name and item metadata, is escrowed. " + BOUNTY_RULES,
|
||||
List.of(action("Create bounty", QuestBoardDialogSpec.Route.SUBMIT_BOUNTY)),
|
||||
List.of(new QuestBoardDialogSpec.Input("bounty_target", "Target player name or UUID", "", 36),
|
||||
new QuestBoardDialogSpec.Input("bounty_kills", "Required kills (positive whole number)", "1", 10)),
|
||||
true
|
||||
);
|
||||
QuestBoardDialogSpec.Screen claims = new QuestBoardDialogSpec.Screen(
|
||||
"Pending Claims",
|
||||
"Pending claims",
|
||||
claimListingText(player),
|
||||
claimant == null && actions == null
|
||||
? List.of()
|
||||
: List.of(action("Collect pending claims", QuestBoardDialogSpec.Route.COLLECT)),
|
||||
List.of(),
|
||||
true
|
||||
);
|
||||
return new QuestBoardDialogSpec(
|
||||
DIALOG_WIDTH, dashboard, browse, create, createBounty, claims, entries
|
||||
);
|
||||
}
|
||||
|
||||
private QuestBoardDialogSpec.QuestEntry questEntry(
|
||||
Quest quest,
|
||||
Player player,
|
||||
java.time.Instant now
|
||||
) {
|
||||
List<QuestBoardDialogSpec.Action> actions = new java.util.ArrayList<>();
|
||||
if (completer != null || this.actions != null) {
|
||||
actions.add(new QuestBoardDialogSpec.Action(
|
||||
"Complete quest", QuestBoardDialogSpec.Route.COMPLETE, quest.id().toString()
|
||||
));
|
||||
}
|
||||
if ((canceller != null || this.actions != null) && quest.issuerId().equals(player.getUniqueId())) {
|
||||
actions.add(new QuestBoardDialogSpec.Action(
|
||||
"Cancel quest", QuestBoardDialogSpec.Route.CANCEL, quest.id().toString()
|
||||
));
|
||||
}
|
||||
return new QuestBoardDialogSpec.QuestEntry(
|
||||
quest.requestedAmount() + " × " + quest.requestedMaterial(),
|
||||
QuestListingFormatter.format(quest, now),
|
||||
actions,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
private QuestBoardDialogSpec.QuestEntry bountyEntry(Bounty bounty, Player player, java.time.Instant now) {
|
||||
String reward = bounty.reward().stream().map(item -> describeItem(item, " × "))
|
||||
.collect(java.util.stream.Collectors.joining(" + "));
|
||||
Duration remaining = Duration.between(now, bounty.expiresAt());
|
||||
long seconds = remaining.getSeconds() + (remaining.getNano() == 0 ? 0 : 1);
|
||||
String time = seconds / 86400 + "d " + seconds % 86400 / 3600 + "h "
|
||||
+ seconds % 3600 / 60 + "m " + seconds % 60 + "s";
|
||||
String details = "BOUNTY " + bounty.id() + " | Target: " + bounty.targetName() + " (" + bounty.targetId()
|
||||
+ ") | Required kills: " + bounty.requiredKills() + " | Your progress: "
|
||||
+ bounty.progress().getOrDefault(player.getUniqueId(), 0) + " / " + bounty.requiredKills()
|
||||
+ " | Reward: " + reward + " | Issuer: " + bounty.issuerName()
|
||||
+ " | Time remaining: " + time + "\n" + BOUNTY_RULES;
|
||||
List<QuestBoardDialogSpec.Action> routes = bounty.issuerId().equals(player.getUniqueId())
|
||||
? List.of(new QuestBoardDialogSpec.Action("Cancel bounty", QuestBoardDialogSpec.Route.CANCEL_BOUNTY, bounty.id().toString()))
|
||||
: List.of();
|
||||
return new QuestBoardDialogSpec.QuestEntry("Bounty: " + bounty.targetName() + " — "
|
||||
+ bounty.requiredKills() + " kills", details, routes, true);
|
||||
}
|
||||
|
||||
private static String describeItem(EscrowItem item, String separator) {
|
||||
return item.amount() + separator + (item.customName() != null
|
||||
? item.customName() + " (" + item.material() + ")"
|
||||
: item.material() + (item.serializedItem() == null ? "" : " (with exact item data)"));
|
||||
}
|
||||
|
||||
private static QuestBoardDialogSpec.Action action(
|
||||
String label,
|
||||
QuestBoardDialogSpec.Route route
|
||||
) {
|
||||
return new QuestBoardDialogSpec.Action(label, route, null);
|
||||
}
|
||||
|
||||
private Dialog renderDashboard(QuestBoardDialogSpec specification, Player player) {
|
||||
List<Dialog> screens = new java.util.ArrayList<>(List.of(
|
||||
renderBrowse(specification, player), renderCreate(specification, player)
|
||||
));
|
||||
if (specification.createBounty() != null) { screens.add(renderCreateBounty(specification, player)); }
|
||||
screens.add(renderClaims(specification, player));
|
||||
return dialog(
|
||||
specification.dashboard(),
|
||||
DialogType.dialogList(
|
||||
RegistrySet.valueSet(RegistryKey.DIALOG, screens), null, 1, 260
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private Dialog renderBrowse(QuestBoardDialogSpec specification, Player player) {
|
||||
ActionButton back = backToDashboard(player);
|
||||
if (specification.questEntries().isEmpty()) {
|
||||
return dialog(specification.browse(), DialogType.notice(back));
|
||||
}
|
||||
List<Dialog> quests = specification.questEntries().stream()
|
||||
.map(entry -> renderQuest(entry, player))
|
||||
.toList();
|
||||
return dialog(
|
||||
specification.browse(),
|
||||
DialogType.dialogList(
|
||||
RegistrySet.valueSet(RegistryKey.DIALOG, quests), back, 1, 260
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private Dialog renderCreate(QuestBoardDialogSpec specification, Player player) {
|
||||
ActionButton create = ActionButton.builder(Component.text("Create quest"))
|
||||
.tooltip(Component.text("Escrow your held stack and publish this quest"))
|
||||
.width(150)
|
||||
.action(DialogAction.customClick((response, audience) -> {
|
||||
if (audience instanceof Player respondingPlayer) {
|
||||
submit(
|
||||
respondingPlayer,
|
||||
response.getText("requested_material"),
|
||||
response.getText("requested_quantity")
|
||||
);
|
||||
}
|
||||
}, ClickCallback.Options.builder()
|
||||
.uses(1)
|
||||
.lifetime(Duration.ofMinutes(10))
|
||||
.build()))
|
||||
.width(180)
|
||||
.action(nativeAction(player, (response, respondingPlayer) -> submit(
|
||||
respondingPlayer, response.getText("requested_material"), response.getText("requested_quantity")
|
||||
)))
|
||||
.build();
|
||||
String listing = listingText();
|
||||
DialogBase base = DialogBase.builder(Component.text("Quest Board"))
|
||||
.externalTitle(Component.text("Quest Board — Active quests and create"))
|
||||
.body(List.of(DialogBody.plainMessage(Component.text(
|
||||
"ACTIVE QUESTS\n" + listing + "\n\nCREATE A QUEST\n"
|
||||
+ "Hold the reward in your main hand. The entire exact stack, including all item metadata, "
|
||||
+ "will be removed and held in escrow only if this quest saves successfully."
|
||||
), 800)))
|
||||
.inputs(List.of(
|
||||
DialogInput.text("requested_material", Component.text("Requested block"))
|
||||
.initial("")
|
||||
.maxLength(64)
|
||||
.build(),
|
||||
DialogInput.text("requested_quantity", Component.text("Quantity"))
|
||||
.initial("64")
|
||||
.maxLength(10)
|
||||
.build()
|
||||
))
|
||||
return dialog(
|
||||
specification.create(),
|
||||
DialogType.multiAction(List.of(create), backToDashboard(player), 1)
|
||||
);
|
||||
}
|
||||
|
||||
private Dialog renderCreateBounty(QuestBoardDialogSpec specification, Player player) {
|
||||
ActionButton create = ActionButton.builder(Component.text("Create bounty"))
|
||||
.tooltip(Component.text("Resolve the target and escrow your exact held stack"))
|
||||
.width(180)
|
||||
.action(nativeAction(player, (response, respondingPlayer) -> submitBounty(
|
||||
respondingPlayer, response.getText("bounty_target"), response.getText("bounty_kills")
|
||||
)))
|
||||
.build();
|
||||
return dialog(specification.createBounty(), DialogType.multiAction(List.of(create), backToDashboard(player), 1));
|
||||
}
|
||||
|
||||
private Dialog renderClaims(QuestBoardDialogSpec specification, Player player) {
|
||||
ActionButton back = backToDashboard(player);
|
||||
if (claimant == null && actions == null) {
|
||||
return dialog(specification.claims(), DialogType.notice(back));
|
||||
}
|
||||
ActionButton collect = callbackButton(player,
|
||||
"Collect pending claims",
|
||||
"Collect earned bounty rewards, delivered items and returned rewards",
|
||||
250,
|
||||
respondingPlayer -> submitClaim(respondingPlayer)
|
||||
);
|
||||
return dialog(
|
||||
specification.claims(), DialogType.multiAction(List.of(collect), back, 1)
|
||||
);
|
||||
}
|
||||
|
||||
private Dialog renderQuest(QuestBoardDialogSpec.QuestEntry entry, Player player) {
|
||||
QuestBoardDialogSpec.Screen screen = new QuestBoardDialogSpec.Screen(
|
||||
entry.title(), entry.title(), entry.message(), entry.actions(), List.of(), true
|
||||
);
|
||||
ActionButton back = backToBrowse(player);
|
||||
List<ActionButton> actions = entry.actions().stream()
|
||||
.map(action -> renderQuestAction(action, player))
|
||||
.toList();
|
||||
return dialog(
|
||||
screen,
|
||||
actions.isEmpty() ? DialogType.notice(back) : DialogType.multiAction(actions, back, 1)
|
||||
);
|
||||
}
|
||||
|
||||
private ActionButton renderQuestAction(QuestBoardDialogSpec.Action action, Player original) {
|
||||
return switch (action.route()) {
|
||||
case COMPLETE -> callbackButton(original,
|
||||
action.label(), "Deliver the requested items", 180,
|
||||
player -> submitCompletion(player, action.questId())
|
||||
);
|
||||
case CANCEL_BOUNTY -> callbackButton(original,
|
||||
action.label(), "Return unpaid escrow to the issuer; no partial hunter payout", 180,
|
||||
player -> actions.cancelBounty(player, action.questId(), clock.instant())
|
||||
);
|
||||
case CANCEL -> callbackButton(original,
|
||||
action.label(), "Return the escrowed reward to pending claims", 180,
|
||||
player -> submitCancellation(player, action.questId())
|
||||
);
|
||||
default -> throw new IllegalArgumentException("Unsupported quest action route");
|
||||
};
|
||||
}
|
||||
|
||||
private Dialog dialog(QuestBoardDialogSpec.Screen screen, DialogType type) {
|
||||
DialogBase.Builder base = DialogBase.builder(Component.text(screen.title()))
|
||||
.externalTitle(Component.text(screen.externalTitle()))
|
||||
.body(List.of(DialogBody.plainMessage(Component.text(screen.message()), DIALOG_WIDTH)))
|
||||
.canCloseWithEscape(true)
|
||||
.pause(false)
|
||||
.afterAction(DialogBase.DialogAfterAction.CLOSE)
|
||||
.afterAction(DialogBase.DialogAfterAction.CLOSE);
|
||||
if (!screen.inputs().isEmpty()) {
|
||||
base.inputs(screen.inputs().stream().map(input -> DialogInput.text(
|
||||
input.key(), Component.text(input.label())
|
||||
)
|
||||
.initial(input.initial())
|
||||
.maxLength(input.maximumLength())
|
||||
.build()).toList());
|
||||
}
|
||||
DialogBase builtBase = base.build();
|
||||
return Dialog.create(factory -> factory.empty().base(builtBase).type(type));
|
||||
}
|
||||
|
||||
private ActionButton backToDashboard(Player player) {
|
||||
return callbackButton(player,
|
||||
"Back", "Return to the quest-board dashboard", 120,
|
||||
respondingPlayer -> respondingPlayer.showDialog(
|
||||
renderDashboard(specification(respondingPlayer), respondingPlayer)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private ActionButton backToBrowse(Player player) {
|
||||
return callbackButton(player,
|
||||
"Back", "Return to active quests", 120,
|
||||
respondingPlayer -> {
|
||||
QuestBoardDialogSpec current = specification(respondingPlayer);
|
||||
respondingPlayer.showDialog(renderBrowse(current, respondingPlayer));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private ActionButton callbackButton(
|
||||
Player original,
|
||||
String label,
|
||||
String tooltip,
|
||||
int width,
|
||||
java.util.function.Consumer<Player> callback
|
||||
) {
|
||||
return ActionButton.builder(Component.text(label))
|
||||
.tooltip(Component.text(tooltip))
|
||||
.width(width)
|
||||
.action(nativeAction(original, (response, respondingPlayer) -> callback.accept(respondingPlayer)))
|
||||
.build();
|
||||
player.showDialog(Dialog.create(factory -> factory.empty()
|
||||
.base(base)
|
||||
.type(DialogType.notice(create))));
|
||||
}
|
||||
|
||||
private DialogAction nativeAction(Player original,
|
||||
java.util.function.BiConsumer<io.papermc.paper.dialog.DialogResponseView, Player> callback) {
|
||||
java.util.UUID audienceId = original.getUniqueId();
|
||||
return DialogAction.customClick((response, audience) -> {
|
||||
if (!(audience instanceof Player respondingPlayer) || respondingPlayer != original) { return; }
|
||||
try {
|
||||
main.execute(() -> {
|
||||
if (Objects.equals(audienceId, respondingPlayer.getUniqueId())
|
||||
&& (actions == null || respondingPlayer.isOnline())) {
|
||||
callback.accept(response, respondingPlayer);
|
||||
}
|
||||
});
|
||||
} catch (java.util.concurrent.RejectedExecutionException ignored) {
|
||||
// Disabled: do not touch Bukkit or remove items after callback rejection.
|
||||
}
|
||||
}, CALLBACK_OPTIONS);
|
||||
}
|
||||
|
||||
void submitBounty(Player player, String target, String killsText) {
|
||||
final int kills;
|
||||
try {
|
||||
kills = Integer.parseInt(killsText == null ? "" : killsText.trim());
|
||||
if (kills <= 0) { throw new NumberFormatException("nonpositive"); }
|
||||
} catch (NumberFormatException exception) {
|
||||
player.sendMessage("Required kills must be a positive whole number.");
|
||||
return;
|
||||
}
|
||||
targets.resolve(target, resolved -> {
|
||||
if (player.isOnline()) {
|
||||
actions.postBounty(player, resolved.id(), resolved.name(), kills, clock.instant());
|
||||
}
|
||||
}, message -> { if (player.isOnline()) { player.sendMessage(message); } });
|
||||
}
|
||||
|
||||
String listingText() {
|
||||
@@ -77,6 +421,103 @@ final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
return QuestListingFormatter.formatAll(browser.activeQuests(now), now);
|
||||
}
|
||||
|
||||
String claimListingText(Player player) {
|
||||
if (claimant == null && service == null) {
|
||||
return "Claim collection is unavailable.";
|
||||
}
|
||||
QuestState snapshot = service == null ? null : service.state();
|
||||
List<QuestClaim> claims = snapshot == null ? claimant.pendingClaims(player)
|
||||
: snapshot.claims().getOrDefault(player.getUniqueId(), List.of());
|
||||
List<String> listings = new java.util.ArrayList<>();
|
||||
claims.forEach(claim -> {
|
||||
String kind = switch (claim.type()) {
|
||||
case DELIVERED_BLOCKS -> "DELIVERED ITEMS";
|
||||
case EARNED_BOUNTY_REWARD -> "EARNED BOUNTY REWARD";
|
||||
case RETURNED_REWARD -> "RETURNED REWARD (" + claim.source().name() + ")";
|
||||
};
|
||||
String items = claim.items().stream()
|
||||
.map(item -> describeItem(item, " "))
|
||||
.reduce((left, right) -> left + ", " + right)
|
||||
.orElseThrow();
|
||||
boolean bounty = claim.source() == QuestClaimSource.BOUNTY
|
||||
|| snapshot != null && snapshot.bounties().containsKey(claim.questId());
|
||||
boolean reserved = snapshot != null && snapshot.transfers().values().stream()
|
||||
.anyMatch(transfer -> claim.id().equals(transfer.claimId()));
|
||||
listings.add(kind + " — " + items + (bounty ? " — bounty " : " — quest ")
|
||||
+ claim.questId() + (reserved ? reservedNotice() : ""));
|
||||
});
|
||||
if (snapshot != null) {
|
||||
snapshot.transfers().values().stream()
|
||||
.filter(transfer -> transfer.ownerId().equals(player.getUniqueId()) && transfer.claimId() == null)
|
||||
.forEach(transfer -> {
|
||||
String kind = snapshot.quests().containsKey(transfer.id())
|
||||
? "EARNED DELIVERY REWARD" : "RECOVERED INPUT";
|
||||
String items = transfer.items().stream().map(item -> describeItem(item, " "))
|
||||
.collect(java.util.stream.Collectors.joining(", "));
|
||||
listings.add(kind + " — " + items + " — transfer " + transfer.id()
|
||||
+ (transfer.status() == ClaimTransfer.Status.RESERVED ? reservedNotice() : ""));
|
||||
});
|
||||
}
|
||||
return listings.isEmpty() ? "No pending claims." : String.join("\n", listings);
|
||||
}
|
||||
|
||||
private static String reservedNotice() {
|
||||
return " — RESERVED: processing or awaiting administrator reconciliation; not redelivered.";
|
||||
}
|
||||
|
||||
List<String> cancellableQuestIds(Player player) {
|
||||
return browser.cancellableQuestIds(player.getUniqueId(), clock.instant());
|
||||
}
|
||||
|
||||
void submitCancellation(Player player, String questId) {
|
||||
if (actions != null) { actions.cancel(player, questId, clock.instant()); return; }
|
||||
if (canceller == null) {
|
||||
player.sendMessage("Quest cancellation is unavailable.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
canceller.cancel(player, questId, clock.instant());
|
||||
} catch (IllegalArgumentException | IllegalStateException exception) {
|
||||
player.sendMessage(exception.getMessage());
|
||||
} catch (IOException exception) {
|
||||
player.sendMessage(
|
||||
"The quest could not be saved. It remains active and its reward remains escrowed."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void submitClaim(Player player) {
|
||||
if (actions != null) { actions.collect(player); return; }
|
||||
if (claimant == null) {
|
||||
player.sendMessage("Quest claims are unavailable.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
claimant.collect(player);
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
player.sendMessage(
|
||||
"Your claim could not be collected safely. It remains pending; please try again."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void submitCompletion(Player player, String questId) {
|
||||
if (actions != null) { actions.complete(player, questId, clock.instant()); return; }
|
||||
if (completer == null) {
|
||||
player.sendMessage("Quest completion is unavailable.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
completer.complete(player, questId, clock.instant());
|
||||
} catch (IllegalArgumentException | IllegalStateException exception) {
|
||||
player.sendMessage(exception.getMessage());
|
||||
} catch (IOException exception) {
|
||||
player.sendMessage(
|
||||
"The quest could not be saved. Your delivered items were restored."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void submit(Player player, String material, String quantityText) {
|
||||
final int quantity;
|
||||
try {
|
||||
@@ -85,6 +526,7 @@ final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
player.sendMessage("Quest quantity must be a positive whole number.");
|
||||
return;
|
||||
}
|
||||
if (actions != null) { actions.create(player, material, quantity, clock.instant()); return; }
|
||||
try {
|
||||
Quest quest = creator.create(player, material, quantity, clock.instant());
|
||||
player.sendMessage("Quest " + quest.id() + " created. Your exact held stack is now escrowed.");
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
final class QuestCancellationController implements QuestCancellationGateway {
|
||||
private final QuestService quests;
|
||||
|
||||
QuestCancellationController(QuestService quests) {
|
||||
this.quests = Objects.requireNonNull(quests, "quests");
|
||||
}
|
||||
|
||||
@Override
|
||||
public QuestClaim cancel(Player player, String questId, Instant cancelledAt) throws IOException {
|
||||
Objects.requireNonNull(player, "player");
|
||||
final UUID id;
|
||||
try {
|
||||
id = UUID.fromString(Objects.requireNonNull(questId, "questId"));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IllegalArgumentException("Quest identifier must be a valid UUID.", exception);
|
||||
}
|
||||
QuestClaim claim = quests.cancel(id, player.getUniqueId(), cancelledAt);
|
||||
player.sendMessage(
|
||||
"Quest " + id + " cancelled. Your exact escrowed reward is ready to claim at a quest board."
|
||||
);
|
||||
return claim;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
interface QuestCancellationGateway {
|
||||
QuestClaim cancel(Player player, String questId, Instant cancelledAt) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
record QuestClaim(
|
||||
UUID id,
|
||||
UUID questId,
|
||||
UUID ownerId,
|
||||
List<EscrowItem> items,
|
||||
Instant createdAt,
|
||||
QuestClaimSource source
|
||||
) {
|
||||
QuestClaim {
|
||||
Objects.requireNonNull(id, "id");
|
||||
Objects.requireNonNull(questId, "questId");
|
||||
Objects.requireNonNull(ownerId, "ownerId");
|
||||
items = List.copyOf(Objects.requireNonNull(items, "items"));
|
||||
if (items.isEmpty()) {
|
||||
throw new IllegalArgumentException("Claim items must not be empty");
|
||||
}
|
||||
Objects.requireNonNull(createdAt, "createdAt");
|
||||
Objects.requireNonNull(source, "source");
|
||||
}
|
||||
|
||||
QuestClaim(
|
||||
UUID id, UUID questId, UUID ownerId, List<EscrowItem> items, Instant createdAt
|
||||
) {
|
||||
this(id, questId, ownerId, items, createdAt, QuestClaimSource.COMPLETION);
|
||||
}
|
||||
|
||||
QuestClaimType type() {
|
||||
return switch (source) {
|
||||
case COMPLETION -> QuestClaimType.DELIVERED_BLOCKS;
|
||||
case BOUNTY -> QuestClaimType.EARNED_BOUNTY_REWARD;
|
||||
case CANCELLATION, EXPIRATION -> QuestClaimType.RETURNED_REWARD;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
final class QuestClaimController implements QuestClaimGateway {
|
||||
private final QuestService quests;
|
||||
private final QuestClaimInventory inventory;
|
||||
|
||||
QuestClaimController(QuestService quests, QuestClaimInventory inventory) {
|
||||
this.quests = Objects.requireNonNull(quests, "quests");
|
||||
this.inventory = Objects.requireNonNull(inventory, "inventory");
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<QuestClaim> pendingClaims(Player player) {
|
||||
Objects.requireNonNull(player, "player");
|
||||
return quests.claimsFor(player.getUniqueId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized ClaimCollectionResult collect(Player player) throws IOException {
|
||||
Objects.requireNonNull(player, "player");
|
||||
List<QuestClaim> claims = quests.claimsFor(player.getUniqueId());
|
||||
if (claims.isEmpty()) {
|
||||
player.sendMessage("You have no pending quest claims.");
|
||||
return new ClaimCollectionResult(0, 0);
|
||||
}
|
||||
|
||||
int collected = 0;
|
||||
int overflow = 0;
|
||||
for (QuestClaim claim : claims) {
|
||||
QuestClaimInventory.PreparedClaim prepared = inventory.prepare(claim.items());
|
||||
QuestClaimInventory.Delivery delivery = prepared.deliver(player);
|
||||
try {
|
||||
quests.acknowledgeClaim(player.getUniqueId(), claim.id());
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
delivery.rollback();
|
||||
throw exception;
|
||||
}
|
||||
collected++;
|
||||
overflow += delivery.overflowAmount();
|
||||
player.sendMessage(description(claim));
|
||||
}
|
||||
player.sendMessage("Collected " + collected + " quest claim(s).");
|
||||
if (overflow > 0) {
|
||||
player.sendMessage(
|
||||
overflow + " item(s) did not fit and were dropped at your feet, protected for you."
|
||||
);
|
||||
}
|
||||
return new ClaimCollectionResult(collected, overflow);
|
||||
}
|
||||
|
||||
private static String description(QuestClaim claim) {
|
||||
if (claim.type() == QuestClaimType.DELIVERED_BLOCKS) {
|
||||
return "Collected delivered items from completed quest " + claim.questId() + ".";
|
||||
}
|
||||
String reason = claim.source() == QuestClaimSource.EXPIRATION
|
||||
? "expired" : "cancelled";
|
||||
return "Collected returned reward from " + reason + " quest " + claim.questId() + ".";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
interface QuestClaimGateway {
|
||||
ClaimCollectionResult collect(Player player) throws IOException;
|
||||
|
||||
default List<QuestClaim> pendingClaims(Player player) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.util.List;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
interface QuestClaimInventory {
|
||||
PreparedClaim prepare(List<EscrowItem> items);
|
||||
|
||||
interface PreparedClaim {
|
||||
Delivery deliver(Player player);
|
||||
}
|
||||
|
||||
interface Delivery {
|
||||
int overflowAmount();
|
||||
void rollback();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
enum QuestClaimSource {
|
||||
COMPLETION,
|
||||
CANCELLATION,
|
||||
EXPIRATION,
|
||||
BOUNTY
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
enum QuestClaimType {
|
||||
DELIVERED_BLOCKS,
|
||||
EARNED_BOUNTY_REWARD,
|
||||
RETURNED_REWARD
|
||||
}
|
||||
@@ -14,26 +14,191 @@ import org.bukkit.entity.Player;
|
||||
|
||||
final class QuestCommand implements CommandExecutor, TabCompleter {
|
||||
private static final List<String> QUANTITIES = List.of("1", "16", "32", "64");
|
||||
private static final double COMMAND_CREATION_DISTANCE = 5.0;
|
||||
private QuestActions actions;
|
||||
private final QuestCreationGateway creator;
|
||||
private final QuestBrowser browser;
|
||||
private final QuestCompletionGateway completer;
|
||||
private final QuestCancellationGateway canceller;
|
||||
private final QuestClaimGateway claimant;
|
||||
private final Clock clock;
|
||||
private final PlayerCommandSettings playerCommands;
|
||||
private final BoardProximity boardProximity;
|
||||
|
||||
QuestCommand(QuestCreationGateway creator, QuestBrowser browser, Clock clock) {
|
||||
this(creator, browser, null, null, null, clock);
|
||||
}
|
||||
|
||||
QuestCommand(
|
||||
QuestCreationGateway creator,
|
||||
QuestBrowser browser,
|
||||
QuestCompletionGateway completer,
|
||||
Clock clock
|
||||
) {
|
||||
this(creator, browser, completer, null, null, clock);
|
||||
}
|
||||
|
||||
QuestCommand(
|
||||
QuestCreationGateway creator,
|
||||
QuestBrowser browser,
|
||||
QuestCompletionGateway completer,
|
||||
QuestCancellationGateway canceller,
|
||||
Clock clock
|
||||
) {
|
||||
this(creator, browser, completer, canceller, null, clock);
|
||||
}
|
||||
|
||||
QuestCommand(
|
||||
QuestCreationGateway creator,
|
||||
QuestBrowser browser,
|
||||
QuestCompletionGateway completer,
|
||||
QuestCancellationGateway canceller,
|
||||
QuestClaimGateway claimant,
|
||||
Clock clock
|
||||
) {
|
||||
this(creator, browser, completer, canceller, claimant, clock, null, null);
|
||||
}
|
||||
|
||||
QuestCommand(
|
||||
QuestCreationGateway creator,
|
||||
QuestBrowser browser,
|
||||
QuestCompletionGateway completer,
|
||||
QuestCancellationGateway canceller,
|
||||
QuestClaimGateway claimant,
|
||||
Clock clock,
|
||||
PlayerCommandSettings playerCommands
|
||||
) {
|
||||
this(creator, browser, completer, canceller, claimant, clock, playerCommands, null);
|
||||
}
|
||||
|
||||
QuestCommand(
|
||||
QuestCreationGateway creator,
|
||||
QuestBrowser browser,
|
||||
QuestCompletionGateway completer,
|
||||
QuestCancellationGateway canceller,
|
||||
QuestClaimGateway claimant,
|
||||
Clock clock,
|
||||
PlayerCommandSettings playerCommands,
|
||||
BoardProximity boardProximity
|
||||
) {
|
||||
this.creator = Objects.requireNonNull(creator, "creator");
|
||||
this.browser = Objects.requireNonNull(browser, "browser");
|
||||
this.completer = completer;
|
||||
this.canceller = canceller;
|
||||
this.claimant = claimant;
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
this.playerCommands = playerCommands;
|
||||
this.boardProximity = boardProximity;
|
||||
}
|
||||
|
||||
QuestCommand(QuestActions actions, QuestService service, Clock clock,
|
||||
PlayerCommandSettings settings, BoardProximity boards) {
|
||||
this(new QuestCreationGateway() {
|
||||
@Override public Quest create(Player player, String material, int amount, Instant at) {
|
||||
throw new IllegalStateException("Runtime creation must use QuestActions");
|
||||
}
|
||||
@Override public List<String> suggestItemMaterials(String prefix) {
|
||||
return new BukkitItemMaterialCatalog().suggest(prefix);
|
||||
}
|
||||
}, service, null, null, null, clock, settings, boards);
|
||||
this.actions = Objects.requireNonNull(actions);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(
|
||||
CommandSender sender, Command command, String label, String[] arguments
|
||||
) {
|
||||
if (!playerCommandsEnabled()) {
|
||||
if (isCreate(arguments) && sender instanceof Player player) {
|
||||
if (!isNearBoard(player)) {
|
||||
sender.sendMessage(
|
||||
"Move closer to a quest board to create a quest by command."
|
||||
);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
sender.sendMessage(
|
||||
"Player quest commands are disabled. Use a physical quest board instead."
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (arguments.length == 0
|
||||
|| (arguments.length == 1 && "list".equalsIgnoreCase(arguments[0]))) {
|
||||
Instant now = clock.instant();
|
||||
sender.sendMessage(QuestListingFormatter.formatAll(browser.activeQuests(now), now));
|
||||
return true;
|
||||
}
|
||||
if (arguments.length == 1 && "claim".equalsIgnoreCase(arguments[0])) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage("Only players can collect quest claims.");
|
||||
return true;
|
||||
}
|
||||
if (actions != null) {
|
||||
actions.collect(player);
|
||||
return true;
|
||||
}
|
||||
if (claimant == null) {
|
||||
sender.sendMessage("Quest claims are unavailable.");
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
claimant.collect(player);
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
sender.sendMessage(
|
||||
"Your claim could not be collected safely. It remains pending; please try again."
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (arguments.length == 2 && "complete".equalsIgnoreCase(arguments[0])) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage("Only players can complete quests with inventory items.");
|
||||
return true;
|
||||
}
|
||||
if (actions != null) {
|
||||
actions.complete(player, arguments[1], clock.instant());
|
||||
return true;
|
||||
}
|
||||
if (completer == null) {
|
||||
sender.sendMessage("Quest completion is unavailable.");
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
completer.complete(player, arguments[1], clock.instant());
|
||||
} catch (IllegalArgumentException | IllegalStateException exception) {
|
||||
sender.sendMessage(exception.getMessage());
|
||||
} catch (IOException exception) {
|
||||
sender.sendMessage(
|
||||
"The quest could not be saved. Your delivered items were restored."
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (arguments.length == 2 && "cancel".equalsIgnoreCase(arguments[0])) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage("Only players can cancel quests.");
|
||||
return true;
|
||||
}
|
||||
if (actions != null) {
|
||||
actions.cancel(player, arguments[1], clock.instant());
|
||||
return true;
|
||||
}
|
||||
if (canceller == null) {
|
||||
sender.sendMessage("Quest cancellation is unavailable.");
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
canceller.cancel(player, arguments[1], clock.instant());
|
||||
} catch (IllegalArgumentException | IllegalStateException exception) {
|
||||
sender.sendMessage(exception.getMessage());
|
||||
} catch (IOException exception) {
|
||||
sender.sendMessage(
|
||||
"The quest could not be saved. It remains active and its reward remains escrowed."
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (arguments.length != 3 || !"create".equalsIgnoreCase(arguments[0])) {
|
||||
usage(sender);
|
||||
return true;
|
||||
@@ -49,6 +214,10 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
|
||||
sender.sendMessage("Quest quantity must be a positive whole number.");
|
||||
return true;
|
||||
}
|
||||
if (actions != null) {
|
||||
actions.create(player, arguments[1], quantity, clock.instant());
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
Quest quest = creator.create(player, arguments[1], quantity, clock.instant());
|
||||
sender.sendMessage("Quest " + quest.id() + " created. Your exact held stack is now escrowed.");
|
||||
@@ -64,14 +233,27 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
|
||||
public List<String> onTabComplete(
|
||||
CommandSender sender, Command command, String alias, String[] arguments
|
||||
) {
|
||||
if (!(sender instanceof Player)) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
return List.of();
|
||||
}
|
||||
if (!playerCommandsEnabled()) {
|
||||
if (!isNearBoard(player)) {
|
||||
return List.of();
|
||||
}
|
||||
if (arguments.length == 1) {
|
||||
return startsWith(List.of("create"), arguments[0]);
|
||||
}
|
||||
if (!isCreate(arguments)) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
if (arguments.length == 1) {
|
||||
return startsWith(List.of("create", "list"), arguments[0]);
|
||||
return startsWith(
|
||||
List.of("create", "list", "complete", "cancel", "claim"), arguments[0]
|
||||
);
|
||||
}
|
||||
if (arguments.length == 2 && "create".equalsIgnoreCase(arguments[0])) {
|
||||
return creator.suggestBlockMaterials(arguments[1]);
|
||||
return creator.suggestItemMaterials(arguments[1]);
|
||||
}
|
||||
if (arguments.length == 3 && "create".equalsIgnoreCase(arguments[0])) {
|
||||
return startsWith(QUANTITIES, arguments[2]);
|
||||
@@ -80,7 +262,6 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
|
||||
return startsWith(browser.completableQuestIds(clock.instant()), arguments[1]);
|
||||
}
|
||||
if (arguments.length == 2 && "cancel".equalsIgnoreCase(arguments[0])) {
|
||||
Player player = (Player) sender;
|
||||
return startsWith(
|
||||
browser.cancellableQuestIds(player.getUniqueId(), clock.instant()), arguments[1]
|
||||
);
|
||||
@@ -88,6 +269,25 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private boolean playerCommandsEnabled() {
|
||||
return playerCommands == null || playerCommands.enabled();
|
||||
}
|
||||
|
||||
private boolean isNearBoard(Player player) {
|
||||
if (boardProximity == null) {
|
||||
return false;
|
||||
}
|
||||
org.bukkit.Location location = player.getLocation();
|
||||
return boardProximity.isWithin(
|
||||
player.getWorld().getUID(), location.getX(), location.getY(), location.getZ(),
|
||||
COMMAND_CREATION_DISTANCE
|
||||
);
|
||||
}
|
||||
|
||||
private static boolean isCreate(String[] arguments) {
|
||||
return arguments.length > 0 && "create".equalsIgnoreCase(arguments[0]);
|
||||
}
|
||||
|
||||
private static List<String> startsWith(List<String> candidates, String prefix) {
|
||||
String normalized = prefix.toLowerCase(Locale.ROOT);
|
||||
return candidates.stream()
|
||||
@@ -96,7 +296,10 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
|
||||
}
|
||||
|
||||
private static void usage(CommandSender sender) {
|
||||
sender.sendMessage("Usage: /quests [list] | /quests create <block> <quantity>");
|
||||
sender.sendMessage(
|
||||
"Usage: /quests [list] | /quests create <item> <quantity> | "
|
||||
+ "/quests complete <quest> | /quests cancel <quest> | /quests claim"
|
||||
);
|
||||
sender.sendMessage("Hold the entire reward stack in your main hand; its exact metadata will be escrowed.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
record QuestCompletion(Quest quest, QuestClaim claim, List<EscrowItem> reward) {
|
||||
QuestCompletion {
|
||||
reward = List.copyOf(reward);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
final class QuestCompletionController implements QuestCompletionGateway {
|
||||
private final QuestService quests;
|
||||
private final QuestCompletionInventory inventory;
|
||||
private final IssuerNotifier notifier;
|
||||
|
||||
QuestCompletionController(
|
||||
QuestService quests, QuestCompletionInventory inventory, IssuerNotifier notifier
|
||||
) {
|
||||
this.quests = Objects.requireNonNull(quests, "quests");
|
||||
this.inventory = Objects.requireNonNull(inventory, "inventory");
|
||||
this.notifier = Objects.requireNonNull(notifier, "notifier");
|
||||
}
|
||||
|
||||
@Override
|
||||
public QuestCompletion complete(Player player, String questId, Instant completedAt)
|
||||
throws IOException {
|
||||
Objects.requireNonNull(player, "player");
|
||||
final UUID id;
|
||||
try {
|
||||
id = UUID.fromString(Objects.requireNonNull(questId, "questId"));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IllegalArgumentException("Quest identifier must be a valid UUID.", exception);
|
||||
}
|
||||
Quest quest = quests.completionCandidate(id, completedAt);
|
||||
QuestCompletionInventory.PreparedReward reward = inventory.prepare(quest.reward());
|
||||
QuestCompletionInventory.RemovedDelivery delivery = inventory.remove(
|
||||
player, quest.requestedMaterial(), quest.requestedAmount()
|
||||
);
|
||||
final QuestCompletion completion;
|
||||
try {
|
||||
completion = quests.complete(id, delivery.items(), completedAt);
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
delivery.rollback();
|
||||
throw exception;
|
||||
}
|
||||
|
||||
int overflow = reward.grantAndDropOverflow(player);
|
||||
player.sendMessage(
|
||||
"Quest " + id + " completed. You received the exact escrowed reward."
|
||||
);
|
||||
if (overflow > 0) {
|
||||
player.sendMessage(
|
||||
overflow + " reward item(s) did not fit and were dropped safely at your feet."
|
||||
);
|
||||
}
|
||||
notifier.notifyIfOnline(completion.quest().issuerId());
|
||||
return completion;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
interface QuestCompletionGateway {
|
||||
QuestCompletion complete(Player player, String questId, Instant completedAt) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.util.List;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
interface QuestCompletionInventory {
|
||||
RemovedDelivery remove(Player player, String material, int amount);
|
||||
|
||||
PreparedReward prepare(List<EscrowItem> reward);
|
||||
|
||||
interface RemovedDelivery {
|
||||
List<EscrowItem> items();
|
||||
void rollback();
|
||||
}
|
||||
|
||||
interface PreparedReward {
|
||||
int grantAndDropOverflow(Player player);
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,12 @@ import org.bukkit.entity.Player;
|
||||
|
||||
final class QuestCreationController implements QuestCreationGateway {
|
||||
private final QuestService quests;
|
||||
private final BlockMaterialCatalog materials;
|
||||
private final ItemMaterialCatalog materials;
|
||||
private final HeldRewardInventory rewards;
|
||||
|
||||
QuestCreationController(
|
||||
QuestService quests,
|
||||
BlockMaterialCatalog materials,
|
||||
ItemMaterialCatalog materials,
|
||||
HeldRewardInventory rewards
|
||||
) {
|
||||
this.quests = Objects.requireNonNull(quests, "quests");
|
||||
@@ -29,9 +29,9 @@ final class QuestCreationController implements QuestCreationGateway {
|
||||
if (requestedAmount <= 0) {
|
||||
throw new IllegalArgumentException("Requested quantity must be positive");
|
||||
}
|
||||
String material = materials.normalizeBlock(requestedMaterial)
|
||||
String material = materials.normalizeItem(requestedMaterial)
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Requested material must be a valid block"
|
||||
"Requested material must be a valid item"
|
||||
));
|
||||
HeldRewardInventory.RemovedReward removed = rewards.remove(player);
|
||||
try {
|
||||
@@ -46,7 +46,7 @@ final class QuestCreationController implements QuestCreationGateway {
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> suggestBlockMaterials(String prefix) {
|
||||
public List<String> suggestItemMaterials(String prefix) {
|
||||
return materials.suggest(prefix == null ? "" : prefix);
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,5 @@ interface QuestCreationGateway {
|
||||
Quest create(Player player, String requestedMaterial, int requestedAmount, Instant createdAt)
|
||||
throws IOException;
|
||||
|
||||
List<String> suggestBlockMaterials(String prefix);
|
||||
List<String> suggestItemMaterials(String prefix);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
final class QuestExpiryTask implements Runnable {
|
||||
private final QuestService quests;
|
||||
private final IssuerNotifier notifier;
|
||||
private final Clock clock;
|
||||
private final Logger logger;
|
||||
|
||||
QuestExpiryTask(
|
||||
QuestService quests, IssuerNotifier notifier, Clock clock, Logger logger
|
||||
) {
|
||||
this.quests = quests;
|
||||
this.notifier = notifier;
|
||||
this.clock = clock;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
final List<QuestClaim> claims;
|
||||
try {
|
||||
claims = quests.expire(clock.instant());
|
||||
} catch (IOException exception) {
|
||||
logger.log(Level.WARNING, "Could not persist expired quests; expiration will be retried", exception);
|
||||
return;
|
||||
}
|
||||
Set<java.util.UUID> owners = new LinkedHashSet<>();
|
||||
claims.forEach(claim -> owners.add(claim.ownerId()));
|
||||
owners.forEach(notifier::notifyIfOnline);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.util.List;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Main-thread-only removal and non-destructive return of exact items across an async yield. */
|
||||
interface QuestInputInventory {
|
||||
Reservation removeReward(Player player);
|
||||
Reservation removeDelivery(Player player, String material, int amount);
|
||||
|
||||
interface Reservation {
|
||||
List<EscrowItem> items();
|
||||
/** Return only these items, never restore a stale inventory snapshot. Failure must be atomic. */
|
||||
void returnTo(Player player);
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,9 @@ final class QuestListingFormatter {
|
||||
}
|
||||
|
||||
private static String formatReward(EscrowItem item) {
|
||||
if (item.customName() != null) {
|
||||
return item.amount() + " × " + item.customName() + " (" + item.material() + ")";
|
||||
}
|
||||
return item.amount() + " × " + item.material()
|
||||
+ (item.serializedItem() == null ? "" : " (with exact item data)");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/** Worker-only, per-canonical-file ownership, including across plugin class-loader generations. */
|
||||
final class QuestPersistenceOwner {
|
||||
// Retain unresolved runtime recovery objects, not just an OS lock eligible for GC cleanup.
|
||||
private static final Map<Path, QuestPersistenceOwner> OWNERS = new HashMap<>();
|
||||
private final Path path;
|
||||
private final FileChannel channel;
|
||||
private final FileLock lock;
|
||||
@SuppressWarnings("unused")
|
||||
private final Object recoveryRoot;
|
||||
|
||||
private QuestPersistenceOwner(Path path, FileChannel channel, FileLock lock, Object recoveryRoot) {
|
||||
this.path = path;
|
||||
this.channel = channel;
|
||||
this.lock = lock;
|
||||
this.recoveryRoot = recoveryRoot;
|
||||
}
|
||||
|
||||
static synchronized QuestPersistenceOwner acquire(Path requested, Object recoveryRoot) throws IOException {
|
||||
Path absolute = requested.toAbsolutePath().normalize();
|
||||
Files.createDirectories(absolute.getParent());
|
||||
Path canonical = Files.exists(absolute) ? absolute.toRealPath()
|
||||
: absolute.getParent().toRealPath().resolve(absolute.getFileName());
|
||||
if (OWNERS.containsKey(canonical)) { throw busy(canonical); }
|
||||
// A sidecar is essential: atomic replacement changes the quests.yml inode.
|
||||
// Never delete the lock file (unlink would permit two different locked inodes).
|
||||
FileChannel channel = FileChannel.open(canonical.resolveSibling(canonical.getFileName() + ".owner.lock"),
|
||||
StandardOpenOption.CREATE, StandardOpenOption.WRITE);
|
||||
try {
|
||||
FileLock lock = channel.tryLock();
|
||||
if (lock == null) { throw busy(canonical); }
|
||||
QuestPersistenceOwner owner = new QuestPersistenceOwner(canonical, channel, lock, recoveryRoot);
|
||||
OWNERS.put(canonical, owner);
|
||||
return owner;
|
||||
} catch (OverlappingFileLockException exception) {
|
||||
channel.close();
|
||||
throw busy(canonical);
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
channel.close();
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
Path path() { return path; }
|
||||
|
||||
void release() throws IOException {
|
||||
synchronized (QuestPersistenceOwner.class) {
|
||||
lock.release();
|
||||
channel.close();
|
||||
OWNERS.remove(path, this);
|
||||
}
|
||||
}
|
||||
|
||||
private static IOException busy(Path path) {
|
||||
return new IOException("Quest persistence is still owned by an earlier runtime: " + path
|
||||
+ "; startup rejected until its drain and recovery complete. Unresolved hot reload is unsupported.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Objects;
|
||||
import java.util.Queue;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/** Single persistence lane with explicit, non-blocking shutdown of Bukkit callbacks. */
|
||||
final class QuestRuntimeScheduler {
|
||||
private final ExecutorService executor;
|
||||
private final Consumer<Runnable> scheduleMain;
|
||||
private final BooleanSupplier isMain;
|
||||
private final Queue<Runnable> callbacks = new ArrayDeque<>();
|
||||
private final ThreadLocal<Boolean> onWorker = ThreadLocal.withInitial(() -> false);
|
||||
private boolean mainClosed;
|
||||
private boolean workerClosing;
|
||||
private final CompletableFuture<Boolean> drained = new CompletableFuture<>();
|
||||
|
||||
CompletionStage<Boolean> drainCompletion() { return drained.minimalCompletionStage(); }
|
||||
|
||||
/**
|
||||
* System.exit ignores non-daemon threads. An orderly JVM shutdown hook instead waits
|
||||
* for the disk-only tail AFTER onDisable has closed/flushed Bukkit callbacks. Never
|
||||
* wait on the server tick thread. A forced halt/kill, a hung disk beyond this bound,
|
||||
* or permanently unavailable storage cannot be made durable by a plugin.
|
||||
*/
|
||||
void installShutdownBoundary(java.util.logging.Logger logger) {
|
||||
Thread hook = Thread.ofPlatform().name("quest-board-shutdown-boundary").unstarted(() -> {
|
||||
try {
|
||||
if (!drained.get(30, java.util.concurrent.TimeUnit.SECONDS)) {
|
||||
logger.severe("Quest server stop INCOMPLETE: unresolved recovery is only retained in memory/logs."
|
||||
+ " Preserve the recovery records; do not assume restart can reconstruct them.");
|
||||
}
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
logger.log(java.util.logging.Level.SEVERE, "Quest server stop INCOMPLETE: disk drain interrupted", exception);
|
||||
} catch (java.util.concurrent.ExecutionException | java.util.concurrent.TimeoutException exception) {
|
||||
logger.log(java.util.logging.Level.SEVERE,
|
||||
"Quest server stop INCOMPLETE: drain did not finish within the 30-second shutdown boundary."
|
||||
+ " Disk durability is not guaranteed; preserve recovery logs and persisted state.", exception);
|
||||
}
|
||||
});
|
||||
Runtime.getRuntime().addShutdownHook(hook);
|
||||
drained.whenComplete((complete, failure) -> {
|
||||
if (failure != null) {
|
||||
logger.log(java.util.logging.Level.SEVERE, "Quest shutdown INCOMPLETE: recovery audit failed; ownership retained", failure);
|
||||
}
|
||||
if (Boolean.TRUE.equals(complete)) {
|
||||
try { Runtime.getRuntime().removeShutdownHook(hook); }
|
||||
catch (IllegalStateException ignored) { /* JVM shutdown already started; hook awaits this completion. */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
QuestRuntimeScheduler(ExecutorService worker, Consumer<Runnable> scheduleMain, BooleanSupplier isMain) {
|
||||
this.executor = Objects.requireNonNull(worker);
|
||||
this.scheduleMain = Objects.requireNonNull(scheduleMain);
|
||||
this.isMain = Objects.requireNonNull(isMain);
|
||||
}
|
||||
|
||||
Executor worker() { return this::executeWorker; }
|
||||
Executor main() { return this::executeMain; }
|
||||
|
||||
private synchronized void executeWorker(Runnable task) {
|
||||
if (workerClosing) {
|
||||
if (!onWorker.get()) { throw new RejectedExecutionException("Quest worker is draining"); }
|
||||
// Recovery spawned by an already accepted operation must finish before the final audit.
|
||||
task.run();
|
||||
return;
|
||||
}
|
||||
executor.execute(() -> {
|
||||
onWorker.set(true);
|
||||
try { task.run(); }
|
||||
finally { onWorker.remove(); }
|
||||
});
|
||||
}
|
||||
|
||||
private synchronized void executeMain(Runnable task) {
|
||||
if (mainClosed) { throw new RejectedExecutionException("Quest main callbacks are closed"); }
|
||||
if (isMain.getAsBoolean()) {
|
||||
task.run();
|
||||
return;
|
||||
}
|
||||
callbacks.add(task);
|
||||
try {
|
||||
scheduleMain.accept(() -> runCallback(task));
|
||||
} catch (RuntimeException exception) {
|
||||
callbacks.remove(task);
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
private void runCallback(Runnable task) {
|
||||
if (!isMain.getAsBoolean()) { throw new IllegalStateException("Quest callback requires main thread"); }
|
||||
synchronized (this) {
|
||||
if (!callbacks.remove(task)) { return; }
|
||||
}
|
||||
task.run();
|
||||
}
|
||||
|
||||
void close(Runnable afterDrain) {
|
||||
closeRecovering(() -> { afterDrain.run(); return true; });
|
||||
}
|
||||
|
||||
void closeRecovering(BooleanSupplier afterDrain) {
|
||||
if (!isMain.getAsBoolean()) { throw new IllegalStateException("Disable must run on main"); }
|
||||
Queue<Runnable> accepted;
|
||||
synchronized (this) {
|
||||
if (mainClosed) { return; }
|
||||
mainClosed = true;
|
||||
accepted = new ArrayDeque<>(callbacks);
|
||||
callbacks.clear();
|
||||
}
|
||||
// Finish callbacks already accepted while Bukkit is still usable inside onDisable.
|
||||
// Later worker callbacks are rejected synchronously, letting AsyncQuestActions persist
|
||||
// returns/released reservations instead of silently losing a queued delivery.
|
||||
for (Runnable callback : accepted) {
|
||||
try { callback.run(); }
|
||||
catch (RuntimeException exception) {
|
||||
System.getLogger(QuestRuntimeScheduler.class.getName()).log(
|
||||
System.Logger.Level.ERROR, "Quest shutdown callback failed; inspect reserved transfers", exception);
|
||||
}
|
||||
}
|
||||
synchronized (this) {
|
||||
workerClosing = true;
|
||||
executor.execute(() -> {
|
||||
onWorker.set(true);
|
||||
try { drained.complete(afterDrain.getAsBoolean()); }
|
||||
catch (Throwable failure) { drained.completeExceptionally(failure); }
|
||||
finally { onWorker.remove(); }
|
||||
});
|
||||
executor.shutdown(); // No join/awaitTermination: the tick never waits on worker/main.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,24 @@ package games.dmg.spigotquestboard;
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
final class QuestService implements QuestBrowser {
|
||||
private final QuestRepository repository;
|
||||
private Map<UUID, Quest> quests;
|
||||
// Immutable snapshots stay readable while a synchronized writer waits on persistence.
|
||||
private volatile QuestState state;
|
||||
|
||||
QuestService(QuestRepository repository) throws IOException {
|
||||
this.repository = Objects.requireNonNull(repository, "repository");
|
||||
quests = new LinkedHashMap<>(repository.load().quests());
|
||||
state = repository.load();
|
||||
}
|
||||
|
||||
synchronized Quest create(
|
||||
@@ -32,29 +36,448 @@ final class QuestService implements QuestBrowser {
|
||||
String material = Objects.requireNonNull(requestedMaterial, "requestedMaterial")
|
||||
.trim().toUpperCase(Locale.ROOT);
|
||||
if (material.isEmpty() || "AIR".equals(material)) {
|
||||
throw new IllegalArgumentException("Requested material must be a block");
|
||||
throw new IllegalArgumentException("Requested material must be a valid item");
|
||||
}
|
||||
Quest quest = new Quest(
|
||||
UUID.randomUUID(), issuerId, issuerName, material, requestedAmount,
|
||||
reward, createdAt, createdAt.plus(7, ChronoUnit.DAYS)
|
||||
);
|
||||
Map<UUID, Quest> candidate = new LinkedHashMap<>(quests);
|
||||
candidate.put(quest.id(), quest);
|
||||
repository.save(new QuestState(candidate));
|
||||
quests = Map.copyOf(candidate);
|
||||
Map<UUID, Quest> quests = new LinkedHashMap<>(state.quests());
|
||||
quests.put(quest.id(), quest);
|
||||
save(new QuestState(quests, state.claims(), state.notifications(), state.bounties()));
|
||||
return quest;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized List<Quest> activeQuests(Instant now) {
|
||||
synchronized Bounty createBounty(
|
||||
UUID issuerId, String issuerName, UUID targetId, String targetName,
|
||||
int requiredKills, List<EscrowItem> reward, Instant createdAt
|
||||
) throws IOException {
|
||||
Bounty bounty = new Bounty(
|
||||
UUID.randomUUID(), issuerId, issuerName, targetId, targetName, requiredKills,
|
||||
reward, createdAt, Objects.requireNonNull(createdAt, "createdAt").plus(7, ChronoUnit.DAYS),
|
||||
QuestStatus.ACTIVE, Map.of(), Set.of(), null
|
||||
);
|
||||
Map<UUID, Bounty> bounties = new LinkedHashMap<>(state.bounties());
|
||||
bounties.put(bounty.id(), bounty);
|
||||
save(new QuestState(state.quests(), state.claims(), state.notifications(), bounties));
|
||||
return bounty;
|
||||
}
|
||||
|
||||
synchronized List<QuestClaim> recordDeath(
|
||||
UUID deathId, UUID targetId, UUID hunterId, Instant at
|
||||
) throws IOException {
|
||||
Objects.requireNonNull(deathId, "deathId");
|
||||
Objects.requireNonNull(targetId, "targetId");
|
||||
Objects.requireNonNull(at, "at");
|
||||
if (hunterId == null || hunterId.equals(targetId)) {
|
||||
return List.of();
|
||||
}
|
||||
Map<UUID, Bounty> bounties = new LinkedHashMap<>(state.bounties());
|
||||
Map<UUID, List<QuestClaim>> claims = mutableClaims(state.claims());
|
||||
Map<UUID, IssuerNotification> notifications = new LinkedHashMap<>(state.notifications());
|
||||
List<QuestClaim> earned = new ArrayList<>();
|
||||
boolean changed = false;
|
||||
for (Bounty active : state.bounties().values()) {
|
||||
if (active.status() != QuestStatus.ACTIVE || !active.targetId().equals(targetId)
|
||||
|| active.issuerId().equals(hunterId) || !at.isAfter(active.createdAt())
|
||||
|| !at.isBefore(active.expiresAt()) || active.processedDeaths().contains(deathId)) {
|
||||
continue;
|
||||
}
|
||||
Map<UUID, Integer> progress = new LinkedHashMap<>(active.progress());
|
||||
int kills = progress.getOrDefault(hunterId, 0) + 1;
|
||||
progress.put(hunterId, kills);
|
||||
Set<UUID> deaths = new LinkedHashSet<>(active.processedDeaths());
|
||||
deaths.add(deathId);
|
||||
boolean won = kills == active.requiredKills();
|
||||
bounties.put(active.id(), new Bounty(
|
||||
active.id(), active.issuerId(), active.issuerName(), active.targetId(),
|
||||
active.targetName(), active.requiredKills(), active.reward(), active.createdAt(),
|
||||
active.expiresAt(), won ? QuestStatus.COMPLETED : QuestStatus.ACTIVE,
|
||||
progress, deaths, won ? hunterId : null
|
||||
));
|
||||
if (won) {
|
||||
QuestClaim claim = new QuestClaim(
|
||||
UUID.randomUUID(), active.id(), hunterId, active.reward(), at,
|
||||
QuestClaimSource.BOUNTY
|
||||
);
|
||||
claims.computeIfAbsent(hunterId, ignored -> new ArrayList<>()).add(claim);
|
||||
IssuerNotification notification = new IssuerNotification(
|
||||
UUID.randomUUID(), active.id(), hunterId,
|
||||
"Bounty " + active.id() + " completed. Your earned reward can be claimed "
|
||||
+ "at a quest board.", at
|
||||
);
|
||||
notifications.put(notification.id(), notification);
|
||||
earned.add(claim);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
save(new QuestState(state.quests(), claims, notifications, bounties));
|
||||
}
|
||||
return List.copyOf(earned);
|
||||
}
|
||||
|
||||
synchronized QuestClaim cancelBounty(UUID id, UUID issuerId, Instant at)
|
||||
throws IOException {
|
||||
Objects.requireNonNull(id, "id");
|
||||
Objects.requireNonNull(issuerId, "issuerId");
|
||||
Objects.requireNonNull(at, "at");
|
||||
Bounty active = state.bounties().get(id);
|
||||
if (active == null) {
|
||||
throw new IllegalArgumentException("Bounty not found: " + id);
|
||||
}
|
||||
if (!active.issuerId().equals(issuerId)) {
|
||||
throw new IllegalArgumentException("Only the bounty issuer can cancel this bounty.");
|
||||
}
|
||||
if (active.status() != QuestStatus.ACTIVE || !at.isBefore(active.expiresAt())) {
|
||||
throw new IllegalStateException("That bounty is no longer active or has expired.");
|
||||
}
|
||||
QuestClaim claim = new QuestClaim(
|
||||
UUID.randomUUID(), id, issuerId, active.reward(), at, QuestClaimSource.CANCELLATION
|
||||
);
|
||||
Map<UUID, Bounty> bounties = new LinkedHashMap<>(state.bounties());
|
||||
bounties.put(id, terminalBounty(active, QuestStatus.CANCELLED));
|
||||
Map<UUID, List<QuestClaim>> claims = mutableClaims(state.claims());
|
||||
claims.computeIfAbsent(issuerId, ignored -> new ArrayList<>()).add(claim);
|
||||
save(new QuestState(state.quests(), claims, state.notifications(), bounties));
|
||||
return claim;
|
||||
}
|
||||
|
||||
List<Bounty> activeBounties(Instant now) {
|
||||
Objects.requireNonNull(now, "now");
|
||||
return quests.values().stream()
|
||||
return state.bounties().values().stream()
|
||||
.filter(bounty -> bounty.status() == QuestStatus.ACTIVE)
|
||||
.filter(bounty -> !now.isBefore(bounty.createdAt()) && now.isBefore(bounty.expiresAt()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Quest completionCandidate(UUID questId, Instant now) {
|
||||
Objects.requireNonNull(questId, "questId");
|
||||
Objects.requireNonNull(now, "now");
|
||||
Quest quest = state.quests().get(questId);
|
||||
if (quest == null) {
|
||||
throw new IllegalArgumentException("Quest not found: " + questId);
|
||||
}
|
||||
if (quest.status() != QuestStatus.ACTIVE || !now.isBefore(quest.expiresAt())) {
|
||||
throw new IllegalStateException("That quest is no longer active or has expired.");
|
||||
}
|
||||
return quest;
|
||||
}
|
||||
|
||||
synchronized QuestCompletion complete(
|
||||
UUID questId, EscrowItem delivered, Instant completedAt
|
||||
) throws IOException {
|
||||
return complete(questId, List.of(delivered), completedAt);
|
||||
}
|
||||
|
||||
synchronized QuestCompletion complete(
|
||||
UUID questId, List<EscrowItem> delivered, Instant completedAt
|
||||
) throws IOException {
|
||||
return completeInternal(questId, delivered, completedAt, null);
|
||||
}
|
||||
|
||||
/** Runtime variant: issuer delivery and collector reward are persisted in ONE transition. */
|
||||
synchronized ClaimTransfer completeFor(
|
||||
UUID questId, UUID collectorId, List<EscrowItem> delivered, Instant completedAt
|
||||
) throws IOException {
|
||||
completeInternal(questId, delivered, completedAt, Objects.requireNonNull(collectorId));
|
||||
return state.transfers().get(questId);
|
||||
}
|
||||
|
||||
private QuestCompletion completeInternal(
|
||||
UUID questId, List<EscrowItem> delivered, Instant completedAt, UUID collectorId
|
||||
) throws IOException {
|
||||
Quest active = completionCandidate(questId, completedAt);
|
||||
List<EscrowItem> delivery = List.copyOf(Objects.requireNonNull(delivered, "delivered"));
|
||||
if (delivery.isEmpty()
|
||||
|| delivery.stream().anyMatch(item -> !item.material().equals(active.requestedMaterial()))
|
||||
|| delivery.stream().mapToInt(EscrowItem::amount).sum() != active.requestedAmount()) {
|
||||
throw new IllegalArgumentException(
|
||||
"Delivery must contain exactly " + active.requestedAmount() + " "
|
||||
+ active.requestedMaterial() + "."
|
||||
);
|
||||
}
|
||||
|
||||
Quest completed = new Quest(
|
||||
active.id(), active.issuerId(), active.issuerName(), active.requestedMaterial(),
|
||||
active.requestedAmount(), active.reward(), active.createdAt(), active.expiresAt(),
|
||||
QuestStatus.COMPLETED
|
||||
);
|
||||
QuestClaim claim = new QuestClaim(
|
||||
UUID.randomUUID(), active.id(), active.issuerId(), delivery, completedAt,
|
||||
QuestClaimSource.COMPLETION
|
||||
);
|
||||
IssuerNotification notification = new IssuerNotification(
|
||||
UUID.randomUUID(), active.id(), active.issuerId(),
|
||||
"Quest " + active.id() + " was completed. Your delivered "
|
||||
+ active.requestedMaterial() + " items can be claimed at a quest board.",
|
||||
completedAt
|
||||
);
|
||||
|
||||
Map<UUID, Quest> quests = new LinkedHashMap<>(state.quests());
|
||||
quests.put(active.id(), completed);
|
||||
Map<UUID, List<QuestClaim>> claims = new LinkedHashMap<>(state.claims());
|
||||
List<QuestClaim> ownerClaims = new ArrayList<>(
|
||||
claims.getOrDefault(active.issuerId(), List.of())
|
||||
);
|
||||
ownerClaims.add(claim);
|
||||
claims.put(active.issuerId(), ownerClaims);
|
||||
Map<UUID, IssuerNotification> notifications = new LinkedHashMap<>(state.notifications());
|
||||
notifications.put(notification.id(), notification);
|
||||
Map<UUID, ClaimTransfer> transfers = new LinkedHashMap<>(state.transfers());
|
||||
if (collectorId != null) {
|
||||
// Quest id is an idempotent journal key for the one completion reward.
|
||||
transfers.put(questId, new ClaimTransfer(questId, collectorId, null, active.reward(),
|
||||
ClaimTransfer.Status.RESERVED));
|
||||
}
|
||||
saveJournal(new QuestState(quests, claims, notifications, state.bounties(), transfers));
|
||||
return new QuestCompletion(completed, claim, completed.reward());
|
||||
}
|
||||
|
||||
synchronized QuestClaim cancel(UUID questId, UUID issuerId, Instant cancelledAt)
|
||||
throws IOException {
|
||||
Objects.requireNonNull(questId, "questId");
|
||||
Objects.requireNonNull(issuerId, "issuerId");
|
||||
Objects.requireNonNull(cancelledAt, "cancelledAt");
|
||||
Quest active = state.quests().get(questId);
|
||||
if (active == null) {
|
||||
throw new IllegalArgumentException("Quest not found: " + questId);
|
||||
}
|
||||
if (!active.issuerId().equals(issuerId)) {
|
||||
throw new IllegalArgumentException("Only the quest issuer can cancel this quest.");
|
||||
}
|
||||
if (active.status() != QuestStatus.ACTIVE || !cancelledAt.isBefore(active.expiresAt())) {
|
||||
throw new IllegalStateException("That quest is no longer active or has expired.");
|
||||
}
|
||||
|
||||
Quest cancelled = new Quest(
|
||||
active.id(), active.issuerId(), active.issuerName(), active.requestedMaterial(),
|
||||
active.requestedAmount(), active.reward(), active.createdAt(), active.expiresAt(),
|
||||
QuestStatus.CANCELLED
|
||||
);
|
||||
QuestClaim claim = new QuestClaim(
|
||||
UUID.randomUUID(), active.id(), active.issuerId(), active.reward(), cancelledAt,
|
||||
QuestClaimSource.CANCELLATION
|
||||
);
|
||||
Map<UUID, Quest> quests = new LinkedHashMap<>(state.quests());
|
||||
quests.put(active.id(), cancelled);
|
||||
Map<UUID, List<QuestClaim>> claims = new LinkedHashMap<>(state.claims());
|
||||
List<QuestClaim> ownerClaims = new ArrayList<>(
|
||||
claims.getOrDefault(active.issuerId(), List.of())
|
||||
);
|
||||
ownerClaims.add(claim);
|
||||
claims.put(active.issuerId(), ownerClaims);
|
||||
save(new QuestState(quests, claims, state.notifications(), state.bounties()));
|
||||
return claim;
|
||||
}
|
||||
|
||||
synchronized List<QuestClaim> expire(Instant now) throws IOException {
|
||||
Objects.requireNonNull(now, "now");
|
||||
Map<UUID, Quest> quests = new LinkedHashMap<>(state.quests());
|
||||
Map<UUID, List<QuestClaim>> claims = mutableClaims(state.claims());
|
||||
Map<UUID, IssuerNotification> notifications = new LinkedHashMap<>(state.notifications());
|
||||
List<QuestClaim> expiredClaims = new ArrayList<>();
|
||||
|
||||
for (Quest active : state.quests().values()) {
|
||||
if (active.status() != QuestStatus.ACTIVE || active.expiresAt().isAfter(now)) {
|
||||
continue;
|
||||
}
|
||||
Quest expired = new Quest(
|
||||
active.id(), active.issuerId(), active.issuerName(), active.requestedMaterial(),
|
||||
active.requestedAmount(), active.reward(), active.createdAt(), active.expiresAt(),
|
||||
QuestStatus.EXPIRED
|
||||
);
|
||||
QuestClaim claim = new QuestClaim(
|
||||
UUID.randomUUID(), active.id(), active.issuerId(), active.reward(), active.expiresAt(),
|
||||
QuestClaimSource.EXPIRATION
|
||||
);
|
||||
IssuerNotification notification = new IssuerNotification(
|
||||
UUID.randomUUID(), active.id(), active.issuerId(),
|
||||
"Quest " + active.id() + " expired. Your returned reward can be claimed "
|
||||
+ "at any quest board or with /quests claim.",
|
||||
active.expiresAt()
|
||||
);
|
||||
quests.put(active.id(), expired);
|
||||
claims.computeIfAbsent(active.issuerId(), ignored -> new ArrayList<>()).add(claim);
|
||||
notifications.put(notification.id(), notification);
|
||||
expiredClaims.add(claim);
|
||||
}
|
||||
Map<UUID, Bounty> bounties = new LinkedHashMap<>(state.bounties());
|
||||
for (Bounty active : state.bounties().values()) {
|
||||
if (active.status() != QuestStatus.ACTIVE || active.expiresAt().isAfter(now)) {
|
||||
continue;
|
||||
}
|
||||
QuestClaim claim = new QuestClaim(
|
||||
UUID.randomUUID(), active.id(), active.issuerId(), active.reward(), active.expiresAt(),
|
||||
QuestClaimSource.EXPIRATION
|
||||
);
|
||||
bounties.put(active.id(), terminalBounty(active, QuestStatus.EXPIRED));
|
||||
claims.computeIfAbsent(active.issuerId(), ignored -> new ArrayList<>()).add(claim);
|
||||
IssuerNotification notification = new IssuerNotification(
|
||||
UUID.randomUUID(), active.id(), active.issuerId(),
|
||||
"Bounty " + active.id() + " expired. Your returned reward can be claimed "
|
||||
+ "at a quest board.", active.expiresAt()
|
||||
);
|
||||
notifications.put(notification.id(), notification);
|
||||
expiredClaims.add(claim);
|
||||
}
|
||||
if (!expiredClaims.isEmpty()) {
|
||||
save(new QuestState(quests, claims, notifications, bounties));
|
||||
}
|
||||
return List.copyOf(expiredClaims);
|
||||
}
|
||||
|
||||
List<QuestClaim> claimsFor(UUID ownerId) {
|
||||
Objects.requireNonNull(ownerId, "ownerId");
|
||||
return state.claims().getOrDefault(ownerId, List.of());
|
||||
}
|
||||
|
||||
synchronized void acknowledgeClaim(UUID ownerId, UUID claimId) throws IOException {
|
||||
Objects.requireNonNull(ownerId, "ownerId");
|
||||
Objects.requireNonNull(claimId, "claimId");
|
||||
if (state.transfers().values().stream().anyMatch(t -> claimId.equals(t.claimId()))) {
|
||||
throw new IllegalStateException("Reserved claims must be acknowledged through their transfer journal");
|
||||
}
|
||||
List<QuestClaim> existing = state.claims().getOrDefault(ownerId, List.of());
|
||||
if (existing.stream().noneMatch(claim -> claim.id().equals(claimId))) {
|
||||
return;
|
||||
}
|
||||
Map<UUID, List<QuestClaim>> claims = mutableClaims(state.claims());
|
||||
List<QuestClaim> remaining = new ArrayList<>(claims.get(ownerId));
|
||||
remaining.removeIf(claim -> claim.id().equals(claimId));
|
||||
if (remaining.isEmpty()) {
|
||||
claims.remove(ownerId);
|
||||
} else {
|
||||
claims.put(ownerId, remaining);
|
||||
}
|
||||
save(new QuestState(state.quests(), claims, state.notifications(), state.bounties()));
|
||||
}
|
||||
|
||||
List<IssuerNotification> pendingNotifications(UUID recipientId) {
|
||||
Objects.requireNonNull(recipientId, "recipientId");
|
||||
return state.notifications().values().stream()
|
||||
.filter(notification -> notification.recipientId().equals(recipientId))
|
||||
.toList();
|
||||
}
|
||||
|
||||
synchronized void markNotificationDelivered(UUID notificationId) throws IOException {
|
||||
if (!state.notifications().containsKey(notificationId)) {
|
||||
return;
|
||||
}
|
||||
Map<UUID, IssuerNotification> notifications = new LinkedHashMap<>(state.notifications());
|
||||
notifications.remove(notificationId);
|
||||
save(new QuestState(state.quests(), state.claims(), notifications, state.bounties()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Quest> activeQuests(Instant now) {
|
||||
Objects.requireNonNull(now, "now");
|
||||
return state.quests().values().stream()
|
||||
.filter(quest -> quest.status() == QuestStatus.ACTIVE)
|
||||
.filter(quest -> now.isBefore(quest.expiresAt()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
synchronized QuestState state() {
|
||||
return new QuestState(quests);
|
||||
QuestState state() {
|
||||
return state;
|
||||
}
|
||||
|
||||
private static Bounty terminalBounty(Bounty active, QuestStatus status) {
|
||||
return new Bounty(
|
||||
active.id(), active.issuerId(), active.issuerName(), active.targetId(),
|
||||
active.targetName(), active.requiredKills(), active.reward(), active.createdAt(),
|
||||
active.expiresAt(), status, active.progress(), active.processedDeaths(), null
|
||||
);
|
||||
}
|
||||
|
||||
private static Map<UUID, List<QuestClaim>> mutableClaims(
|
||||
Map<UUID, List<QuestClaim>> source
|
||||
) {
|
||||
Map<UUID, List<QuestClaim>> copy = new LinkedHashMap<>();
|
||||
source.forEach((owner, claims) -> copy.put(owner, new ArrayList<>(claims)));
|
||||
return copy;
|
||||
}
|
||||
|
||||
private void save(QuestState candidate) throws IOException {
|
||||
saveJournal(new QuestState(candidate.quests(), candidate.claims(), candidate.notifications(),
|
||||
candidate.bounties(), state.transfers()));
|
||||
}
|
||||
|
||||
private void saveJournal(QuestState candidate) throws IOException {
|
||||
repository.save(candidate);
|
||||
state = candidate;
|
||||
}
|
||||
|
||||
/** Idempotent durable recovery after an input could not be returned to a live inventory. */
|
||||
synchronized void retainReturn(ClaimTransfer transfer) throws IOException {
|
||||
if (state.transfers().containsKey(transfer.id())) {
|
||||
return;
|
||||
}
|
||||
Map<UUID, ClaimTransfer> transfers = new LinkedHashMap<>(state.transfers());
|
||||
transfers.put(transfer.id(), transfer);
|
||||
saveJournal(new QuestState(state.quests(), state.claims(), state.notifications(), state.bounties(), transfers));
|
||||
}
|
||||
|
||||
/** Atomically quarantine all currently collectible claims before any inventory delivery. */
|
||||
synchronized List<ClaimTransfer> reserveClaims(UUID ownerId) throws IOException {
|
||||
Map<UUID, ClaimTransfer> transfers = new LinkedHashMap<>(state.transfers());
|
||||
List<ClaimTransfer> reserved = new ArrayList<>();
|
||||
for (QuestClaim claim : claimsFor(ownerId)) {
|
||||
if (transfers.values().stream().anyMatch(t -> claim.id().equals(t.claimId()))) {
|
||||
continue;
|
||||
}
|
||||
ClaimTransfer transfer = new ClaimTransfer(UUID.randomUUID(), ownerId, claim.id(),
|
||||
claim.items(), ClaimTransfer.Status.RESERVED);
|
||||
transfers.put(transfer.id(), transfer);
|
||||
reserved.add(transfer);
|
||||
}
|
||||
for (ClaimTransfer transfer : state.transfers().values()) {
|
||||
if (transfer.ownerId().equals(ownerId) && transfer.status() == ClaimTransfer.Status.PENDING) {
|
||||
ClaimTransfer reservation = transfer.reserved();
|
||||
transfers.put(reservation.id(), reservation);
|
||||
reserved.add(reservation);
|
||||
}
|
||||
}
|
||||
if (!reserved.isEmpty()) {
|
||||
saveJournal(new QuestState(state.quests(), state.claims(), state.notifications(),
|
||||
state.bounties(), transfers));
|
||||
}
|
||||
return List.copyOf(reserved);
|
||||
}
|
||||
|
||||
synchronized void acknowledgeTransfer(UUID id) throws IOException {
|
||||
ClaimTransfer transfer = state.transfers().get(id);
|
||||
if (transfer == null) {
|
||||
return;
|
||||
}
|
||||
Map<UUID, ClaimTransfer> transfers = new LinkedHashMap<>(state.transfers());
|
||||
transfers.remove(id);
|
||||
Map<UUID, List<QuestClaim>> claims = mutableClaims(state.claims());
|
||||
if (transfer.claimId() != null) {
|
||||
List<QuestClaim> remaining = claims.get(transfer.ownerId());
|
||||
if (remaining != null) {
|
||||
remaining.removeIf(claim -> claim.id().equals(transfer.claimId()));
|
||||
if (remaining.isEmpty()) {
|
||||
claims.remove(transfer.ownerId());
|
||||
}
|
||||
}
|
||||
}
|
||||
saveJournal(new QuestState(state.quests(), claims, state.notifications(), state.bounties(), transfers));
|
||||
}
|
||||
|
||||
/** Only use when this process knows delivery did not happen (or synchronously rolled back). */
|
||||
synchronized void releaseTransfer(UUID id) throws IOException {
|
||||
ClaimTransfer transfer = state.transfers().get(id);
|
||||
if (transfer == null) {
|
||||
return;
|
||||
}
|
||||
Map<UUID, ClaimTransfer> transfers = new LinkedHashMap<>(state.transfers());
|
||||
if (transfer.claimId() == null) {
|
||||
transfers.put(id, new ClaimTransfer(id, transfer.ownerId(), null, transfer.items(),
|
||||
ClaimTransfer.Status.PENDING));
|
||||
} else {
|
||||
transfers.remove(id);
|
||||
}
|
||||
saveJournal(new QuestState(state.quests(), state.claims(), state.notifications(), state.bounties(), transfers));
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,87 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
record QuestState(Map<UUID, Quest> quests) {
|
||||
record QuestState(
|
||||
Map<UUID, Quest> quests,
|
||||
Map<UUID, List<QuestClaim>> claims,
|
||||
Map<UUID, IssuerNotification> notifications,
|
||||
Map<UUID, Bounty> bounties,
|
||||
Map<UUID, ClaimTransfer> transfers
|
||||
) {
|
||||
QuestState {
|
||||
transfers = Map.copyOf(Objects.requireNonNull(transfers, "transfers"));
|
||||
for (Map.Entry<UUID, ClaimTransfer> entry : transfers.entrySet()) {
|
||||
if (!entry.getKey().equals(entry.getValue().id())) {
|
||||
throw new IllegalArgumentException("Transfer map key does not match id");
|
||||
}
|
||||
}
|
||||
quests = Map.copyOf(Objects.requireNonNull(quests, "quests"));
|
||||
for (Map.Entry<UUID, Quest> entry : quests.entrySet()) {
|
||||
if (!entry.getKey().equals(entry.getValue().id())) {
|
||||
throw new IllegalArgumentException("Quest map key does not match quest id");
|
||||
}
|
||||
}
|
||||
Map<UUID, List<QuestClaim>> immutableClaims = new LinkedHashMap<>();
|
||||
for (Map.Entry<UUID, List<QuestClaim>> entry
|
||||
: Objects.requireNonNull(claims, "claims").entrySet()) {
|
||||
List<QuestClaim> ownerClaims = List.copyOf(entry.getValue());
|
||||
if (ownerClaims.stream().anyMatch(claim -> !claim.ownerId().equals(entry.getKey()))) {
|
||||
throw new IllegalArgumentException("Claim map key does not match owner id");
|
||||
}
|
||||
immutableClaims.put(entry.getKey(), ownerClaims);
|
||||
}
|
||||
claims = Map.copyOf(immutableClaims);
|
||||
java.util.Set<UUID> reservedClaims = new java.util.HashSet<>();
|
||||
for (ClaimTransfer transfer : transfers.values()) {
|
||||
if (transfer.claimId() == null) {
|
||||
continue;
|
||||
}
|
||||
QuestClaim claim = claims.getOrDefault(transfer.ownerId(), List.of()).stream()
|
||||
.filter(candidate -> candidate.id().equals(transfer.claimId())).findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("Transfer must reference its owner's pending claim"));
|
||||
if (transfer.status() != ClaimTransfer.Status.RESERVED || !claim.items().equals(transfer.items())
|
||||
|| !reservedClaims.add(transfer.claimId())) {
|
||||
throw new IllegalArgumentException("Invalid or duplicate claim transfer reservation");
|
||||
}
|
||||
}
|
||||
bounties = Map.copyOf(Objects.requireNonNull(bounties, "bounties"));
|
||||
for (Map.Entry<UUID, Bounty> entry : bounties.entrySet()) {
|
||||
if (!entry.getKey().equals(entry.getValue().id())) {
|
||||
throw new IllegalArgumentException("Bounty map key does not match bounty id");
|
||||
}
|
||||
}
|
||||
notifications = Map.copyOf(Objects.requireNonNull(notifications, "notifications"));
|
||||
for (Map.Entry<UUID, IssuerNotification> entry : notifications.entrySet()) {
|
||||
if (!entry.getKey().equals(entry.getValue().id())) {
|
||||
throw new IllegalArgumentException("Notification map key does not match id");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QuestState(
|
||||
Map<UUID, Quest> quests, Map<UUID, List<QuestClaim>> claims,
|
||||
Map<UUID, IssuerNotification> notifications, Map<UUID, Bounty> bounties
|
||||
) {
|
||||
this(quests, claims, notifications, bounties, Map.of());
|
||||
}
|
||||
|
||||
QuestState(
|
||||
Map<UUID, Quest> quests, Map<UUID, List<QuestClaim>> claims,
|
||||
Map<UUID, IssuerNotification> notifications
|
||||
) {
|
||||
this(quests, claims, notifications, Map.of());
|
||||
}
|
||||
|
||||
QuestState(Map<UUID, Quest> quests) {
|
||||
this(quests, Map.of(), Map.of());
|
||||
}
|
||||
|
||||
static QuestState empty() {
|
||||
return new QuestState(Map.of());
|
||||
return new QuestState(Map.of(), Map.of(), Map.of());
|
||||
}
|
||||
}
|
||||
@@ -1,51 +1,216 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.command.PluginCommand;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
public final class SpigotQuestBoardPlugin extends JavaPlugin {
|
||||
private QuestRuntimeScheduler runtime;
|
||||
private volatile QuestService loadedQuests;
|
||||
private volatile QuestActions loadedActions;
|
||||
private volatile QuestPersistenceOwner persistenceOwner;
|
||||
private volatile boolean stopping;
|
||||
private java.util.Queue<BountyDeathListener.Death> startupDeaths;
|
||||
private QuestActions liveActions;
|
||||
|
||||
QuestRuntimeScheduler createRuntime() {
|
||||
Server server = getServer();
|
||||
return new QuestRuntimeScheduler(
|
||||
Executors.newSingleThreadExecutor(Thread.ofPlatform().daemon(false).name("quest-board-state").factory()),
|
||||
task -> server.getScheduler().runTask(this, task),
|
||||
server::isPrimaryThread
|
||||
);
|
||||
}
|
||||
|
||||
Clock runtimeClock() { return Clock.systemUTC(); }
|
||||
|
||||
QuestRepository questRepository(Path path, QuestRuntimeScheduler scheduler) {
|
||||
return new YamlQuestRepository(path, scheduler.main());
|
||||
}
|
||||
|
||||
QuestActions runtimeActions(QuestService service, QuestRuntimeScheduler scheduler) {
|
||||
return new AsyncQuestActions(service, scheduler.worker(), scheduler.main(), getLogger());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
final BoardRegistry boards;
|
||||
final QuestService quests;
|
||||
if (runtime != null) {
|
||||
var completion = runtime.drainCompletion().toCompletableFuture();
|
||||
if (!completion.isDone() || completion.isCompletedExceptionally() || !completion.getNow(false)) {
|
||||
getLogger().severe("Quest startup rejected: previous runtime has not completed drain/recovery."
|
||||
+ " Unresolved plugin hot reload is unsupported; do not replace its persistence owner.");
|
||||
getServer().getPluginManager().disablePlugin(this);
|
||||
return;
|
||||
}
|
||||
}
|
||||
loadedQuests = null;
|
||||
loadedActions = null;
|
||||
persistenceOwner = null;
|
||||
stopping = false;
|
||||
Path folder = getDataFolder().toPath();
|
||||
Server server = getServer();
|
||||
Logger logger = getLogger();
|
||||
runtime = createRuntime();
|
||||
QuestRuntimeScheduler scheduler = runtime;
|
||||
scheduler.installShutdownBoundary(logger);
|
||||
startupDeaths = new java.util.concurrent.ConcurrentLinkedQueue<>();
|
||||
liveActions = null;
|
||||
// Observe on main before the worker can load. Retain only immutable UUID/time data,
|
||||
// never Player/Event objects, and preserve native MONITOR/ignoreCancelled semantics.
|
||||
server.getPluginManager().registerEvents(new BountyDeathListener(death -> {
|
||||
if (stopping) { return; }
|
||||
if (liveActions == null) { startupDeaths.add(death); }
|
||||
else { death.replay(liveActions); }
|
||||
}, runtimeClock()), this);
|
||||
scheduler.worker().execute(() -> {
|
||||
try {
|
||||
persistenceOwner = QuestPersistenceOwner.acquire(folder.resolve("quests.yml"), this);
|
||||
BoardRegistry boards = new BoardRegistry(new YamlBoardRepository(folder.resolve("boards.yml")));
|
||||
QuestService quests = new QuestService(questRepository(persistenceOwner.path(), scheduler));
|
||||
loadedQuests = quests;
|
||||
loadedActions = runtimeActions(quests, scheduler);
|
||||
PlayerCommandSettings commands = new PlayerCommandSettings(
|
||||
new YamlPlayerCommandSettingsRepository(folder.resolve("settings.yml")));
|
||||
scheduler.main().execute(() -> start(boards, quests, commands));
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
if (stopping && exception instanceof RejectedExecutionException) { return; }
|
||||
logger.log(Level.SEVERE, "Could not load plugin state", exception);
|
||||
try {
|
||||
scheduler.main().execute(() -> server.getPluginManager().disablePlugin(this));
|
||||
} catch (RejectedExecutionException ignored) { /* Already disabling. */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void start(BoardRegistry boards, QuestService quests, PlayerCommandSettings playerCommands) {
|
||||
if (stopping) { return; }
|
||||
final int upgradedBoards;
|
||||
try {
|
||||
boards = new BoardRegistry(new YamlBoardRepository(
|
||||
getDataFolder().toPath().resolve("boards.yml")
|
||||
));
|
||||
quests = new QuestService(new YamlQuestRepository(
|
||||
getDataFolder().toPath().resolve("quests.yml")
|
||||
));
|
||||
upgradedBoards = upgradePhysicalBoards(boards, worldId -> {
|
||||
org.bukkit.World world = getServer().getWorld(worldId);
|
||||
return world == null ? null : new BukkitPhysicalBoardWorld(world);
|
||||
});
|
||||
} catch (IOException exception) {
|
||||
getLogger().log(Level.SEVERE, "Could not load quest board state", exception);
|
||||
getLogger().log(Level.SEVERE, "Could not upgrade physical boards", exception);
|
||||
getServer().getPluginManager().disablePlugin(this);
|
||||
return;
|
||||
}
|
||||
|
||||
QuestAdminCommand admin = new QuestAdminCommand(boards);
|
||||
command("questadmin").setExecutor(admin);
|
||||
|
||||
QuestCreationGateway creator = new QuestCreationController(
|
||||
quests, new BukkitBlockMaterialCatalog(), new BukkitHeldRewardInventory()
|
||||
);
|
||||
QuestCommand questCommand = new QuestCommand(creator, quests, Clock.systemUTC());
|
||||
command("quests").setExecutor(questCommand);
|
||||
command("quests").setTabCompleter(questCommand);
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new QuestBoardInteractionListener(
|
||||
boards, new QuestBoardDialogUi(creator, quests, Clock.systemUTC())
|
||||
),
|
||||
this
|
||||
);
|
||||
getLogger().info(
|
||||
"Spigot Quest Board enabled with " + boards.size() + " boards and "
|
||||
+ quests.state().quests().size() + " quests."
|
||||
);
|
||||
Clock clock = runtimeClock();
|
||||
QuestActions actions = loadedActions;
|
||||
replayStartupDeaths(actions);
|
||||
liveActions = actions;
|
||||
CommandHandlers handlers = commandHandlers(boards, playerCommands, actions, quests, clock);
|
||||
command("questadmin").setExecutor(handlers.admin());
|
||||
command("questadmin").setTabCompleter(handlers.admin());
|
||||
command("quests").setExecutor(handlers.quests());
|
||||
command("quests").setTabCompleter(handlers.quests());
|
||||
getServer().getPluginManager().registerEvents(new RuntimeJoinListener(actions, quests, clock), this);
|
||||
getServer().getPluginManager().registerEvents(new QuestBoardInteractionListener(
|
||||
boards, new QuestBoardDialogUi(actions, quests,
|
||||
new BountyTargetResolver(getServer(), runtime.main()), runtime.main(), clock)), this);
|
||||
getServer().getScheduler().runTaskTimer(this, () -> actions.expire(clock.instant()), 0L, 20L);
|
||||
// Players can join during asynchronous startup; do not lose their pending-item reminder.
|
||||
getServer().getOnlinePlayers().forEach(actions::joined);
|
||||
getLogger().info("Spigot Quest Board enabled with " + boards.size() + " boards and "
|
||||
+ quests.state().quests().size() + " quests, " + quests.state().bounties().size()
|
||||
+ " bounties; upgraded " + upgradedBoards + " physical boards.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
stopping = true;
|
||||
if (runtime == null) { return; }
|
||||
Logger logger = getLogger();
|
||||
runtime.closeRecovering(() -> {
|
||||
if (loadedActions != null) { replayStartupDeaths(loadedActions); }
|
||||
if (startupDeaths != null && !startupDeaths.isEmpty()) {
|
||||
logger.severe("Quest shutdown INCOMPLETE; startup never loaded. Ownership retained where acquired."
|
||||
+ " Recoverable captured deaths=" + java.util.List.copyOf(startupDeaths));
|
||||
return false;
|
||||
}
|
||||
if (loadedActions instanceof AsyncQuestActions async && !async.shutdownRecovery()) {
|
||||
return false;
|
||||
}
|
||||
QuestService service = loadedQuests;
|
||||
if (service != null) {
|
||||
service.state().transfers().values().stream()
|
||||
.filter(transfer -> transfer.status() == ClaimTransfer.Status.RESERVED)
|
||||
.forEach(transfer -> logger.warning("Quest transfer remains reserved; manual recovery required: " + transfer.id()));
|
||||
}
|
||||
try {
|
||||
if (persistenceOwner != null) { persistenceOwner.release(); }
|
||||
} catch (IOException exception) {
|
||||
logger.log(Level.SEVERE, "Quest shutdown INCOMPLETE; could not release persistence ownership", exception);
|
||||
return false;
|
||||
}
|
||||
logger.info("Quest persistence drain and retained recovery complete; ownership released."
|
||||
+ " Any quarantined RESERVED transfers still require manual reconciliation.");
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private void replayStartupDeaths(QuestActions actions) {
|
||||
while (startupDeaths != null && !startupDeaths.isEmpty()) {
|
||||
startupDeaths.element().replay(actions);
|
||||
startupDeaths.remove();
|
||||
}
|
||||
}
|
||||
|
||||
static final class RuntimeJoinListener implements Listener {
|
||||
private final QuestActions actions;
|
||||
private final QuestService quests;
|
||||
private final Clock clock;
|
||||
RuntimeJoinListener(QuestActions actions, QuestService quests, Clock clock) {
|
||||
this.actions = actions;
|
||||
this.quests = quests;
|
||||
this.clock = clock;
|
||||
}
|
||||
@EventHandler public void onJoin(PlayerJoinEvent event) {
|
||||
var now = clock.instant();
|
||||
int count = quests.activeQuests(now).size() + quests.activeBounties(now).size();
|
||||
String summary = switch (count) {
|
||||
case 0 -> "No quests are currently available.";
|
||||
case 1 -> "1 quest is currently available.";
|
||||
default -> count + " quests are currently available.";
|
||||
};
|
||||
event.getPlayer().sendMessage(summary + " Visit the quest board to accept or create a quest.");
|
||||
actions.joined(event.getPlayer());
|
||||
}
|
||||
}
|
||||
|
||||
static int upgradePhysicalBoards(BoardRegistry boards, PhysicalBoardUpgrader.WorldFactory worlds)
|
||||
throws IOException {
|
||||
return new PhysicalBoardUpgrader(boards, worlds).upgrade();
|
||||
}
|
||||
|
||||
static CommandHandlers commandHandlers(BoardRegistry boards, PlayerCommandSettings playerCommands,
|
||||
QuestActions actions, QuestService quests, Clock clock) {
|
||||
return new CommandHandlers(new QuestAdminCommand(boards, playerCommands),
|
||||
new QuestCommand(actions, quests, clock, playerCommands, boards));
|
||||
}
|
||||
|
||||
static CommandHandlers commandHandlers(
|
||||
BoardRegistry boards, PlayerCommandSettings playerCommands,
|
||||
QuestCreationGateway creator, QuestBrowser browser, QuestCompletionGateway completer,
|
||||
QuestCancellationGateway canceller, QuestClaimGateway claimant, Clock clock
|
||||
) {
|
||||
return new CommandHandlers(new QuestAdminCommand(boards, playerCommands),
|
||||
new QuestCommand(creator, browser, completer, canceller, claimant, clock, playerCommands, boards));
|
||||
}
|
||||
|
||||
record CommandHandlers(QuestAdminCommand admin, QuestCommand quests) { }
|
||||
|
||||
private PluginCommand command(String name) {
|
||||
return Objects.requireNonNull(getCommand(name), "Missing command metadata for " + name);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Objects;
|
||||
import org.bukkit.configuration.InvalidConfigurationException;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
|
||||
final class YamlPlayerCommandSettingsRepository implements PlayerCommandSettingsRepository {
|
||||
private static final String ENABLED_PATH = "player-commands.enabled";
|
||||
private final Path path;
|
||||
|
||||
YamlPlayerCommandSettingsRepository(Path path) {
|
||||
this.path = Objects.requireNonNull(path, "path");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean loadEnabled() throws IOException {
|
||||
if (!Files.exists(path)) {
|
||||
return false;
|
||||
}
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
try {
|
||||
yaml.load(path.toFile());
|
||||
} catch (InvalidConfigurationException exception) {
|
||||
throw new IOException("Invalid player command settings", exception);
|
||||
}
|
||||
return yaml.getBoolean(ENABLED_PATH, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveEnabled(boolean enabled) throws IOException {
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
yaml.set(ENABLED_PATH, enabled);
|
||||
Path parent = path.toAbsolutePath().getParent();
|
||||
Files.createDirectories(parent);
|
||||
Path temporary = Files.createTempFile(parent, path.getFileName().toString(), ".tmp");
|
||||
try {
|
||||
yaml.save(temporary.toFile());
|
||||
try {
|
||||
Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException exception) {
|
||||
Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,17 +9,30 @@ import java.time.Instant;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.configuration.InvalidConfigurationException;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
|
||||
final class YamlQuestRepository implements QuestRepository {
|
||||
private final Path path;
|
||||
private final java.util.concurrent.Executor itemDecoder;
|
||||
|
||||
/** Legacy synchronous loader, retained for existing gateways/tests. */
|
||||
YamlQuestRepository(Path path) {
|
||||
this(path, Runnable::run);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime loader: call load on the I/O worker; dispatch legacy Bukkit item decoding to main.
|
||||
* Do not block main waiting for load: decoding needs that executor to remain available.
|
||||
*/
|
||||
YamlQuestRepository(Path path, java.util.concurrent.Executor main) {
|
||||
this.path = path;
|
||||
this.itemDecoder = java.util.Objects.requireNonNull(main);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -33,8 +46,24 @@ final class YamlQuestRepository implements QuestRepository {
|
||||
} catch (InvalidConfigurationException exception) {
|
||||
throw new IOException("Invalid quest state", exception);
|
||||
}
|
||||
Map<UUID, ClaimTransfer> transfers = new LinkedHashMap<>();
|
||||
Map<UUID, Bounty> bounties = new LinkedHashMap<>();
|
||||
Map<UUID, Quest> quests = new LinkedHashMap<>();
|
||||
Map<UUID, List<QuestClaim>> claims = new LinkedHashMap<>();
|
||||
Map<UUID, IssuerNotification> notifications = new LinkedHashMap<>();
|
||||
try {
|
||||
for (Map<?, ?> entry : readBountyEntries(yaml.get("transfers"))) {
|
||||
ClaimTransfer transfer = new ClaimTransfer(
|
||||
UUID.fromString(requiredString(entry, "id")),
|
||||
UUID.fromString(requiredString(entry, "owner-id")),
|
||||
entry.get("claim-id") == null ? null : UUID.fromString(requiredString(entry, "claim-id")),
|
||||
readItems(entry.get("items"), "transfer items"),
|
||||
ClaimTransfer.Status.valueOf(requiredString(entry, "status"))
|
||||
);
|
||||
if (transfers.put(transfer.id(), transfer) != null) {
|
||||
throw new IllegalArgumentException("Duplicate transfer id: " + transfer.id());
|
||||
}
|
||||
}
|
||||
for (Map<?, ?> entry : yaml.getMapList("quests")) {
|
||||
UUID id = UUID.fromString(requiredString(entry, "id"));
|
||||
Quest quest = new Quest(
|
||||
@@ -43,7 +72,7 @@ final class YamlQuestRepository implements QuestRepository {
|
||||
requiredString(entry, "issuer-name"),
|
||||
requiredString(entry, "requested-material"),
|
||||
requiredInteger(entry, "requested-amount"),
|
||||
readRewards(entry.get("reward")),
|
||||
readItems(entry.get("reward"), "reward"),
|
||||
Instant.parse(requiredString(entry, "created-at")),
|
||||
Instant.parse(requiredString(entry, "expires-at")),
|
||||
readStatus(entry.get("status"))
|
||||
@@ -52,10 +81,62 @@ final class YamlQuestRepository implements QuestRepository {
|
||||
throw new IllegalArgumentException("Duplicate quest id: " + id);
|
||||
}
|
||||
}
|
||||
for (Map<?, ?> entry : readBountyEntries(yaml.get("bounties"))) {
|
||||
Bounty bounty = new Bounty(
|
||||
UUID.fromString(requiredString(entry, "id")),
|
||||
UUID.fromString(requiredString(entry, "issuer-id")),
|
||||
requiredString(entry, "issuer-name"),
|
||||
UUID.fromString(requiredString(entry, "target-id")),
|
||||
requiredString(entry, "target-name"),
|
||||
requiredInteger(entry, "required-kills"),
|
||||
readItems(entry.get("reward"), "reward"),
|
||||
Instant.parse(requiredString(entry, "created-at")),
|
||||
Instant.parse(requiredString(entry, "expires-at")),
|
||||
QuestStatus.valueOf(requiredString(entry, "status")),
|
||||
readProgress(entry.get("progress")),
|
||||
readDeaths(entry.get("processed-deaths")),
|
||||
entry.get("winner-id") == null ? null
|
||||
: UUID.fromString(requiredString(entry, "winner-id"))
|
||||
);
|
||||
if (bounties.put(bounty.id(), bounty) != null) {
|
||||
throw new IllegalArgumentException("Duplicate bounty id: " + bounty.id());
|
||||
}
|
||||
}
|
||||
for (Map<?, ?> entry : yaml.getMapList("claims")) {
|
||||
QuestClaim claim = new QuestClaim(
|
||||
UUID.fromString(requiredString(entry, "id")),
|
||||
UUID.fromString(requiredString(entry, "quest-id")),
|
||||
UUID.fromString(requiredString(entry, "owner-id")),
|
||||
readItems(entry.get("items"), "claim items"),
|
||||
Instant.parse(requiredString(entry, "created-at")),
|
||||
readClaimSource(entry.get("source"), quests.get(
|
||||
UUID.fromString(requiredString(entry, "quest-id"))
|
||||
))
|
||||
);
|
||||
claims.computeIfAbsent(claim.ownerId(), ignored -> new ArrayList<>()).add(claim);
|
||||
}
|
||||
for (Map<?, ?> entry : yaml.getMapList("notifications")) {
|
||||
IssuerNotification notification = new IssuerNotification(
|
||||
UUID.fromString(requiredString(entry, "id")),
|
||||
UUID.fromString(requiredString(entry, "quest-id")),
|
||||
UUID.fromString(requiredString(entry, "recipient-id")),
|
||||
requiredString(entry, "message"),
|
||||
Instant.parse(requiredString(entry, "created-at"))
|
||||
);
|
||||
if (notifications.put(notification.id(), notification) != null) {
|
||||
throw new IllegalArgumentException(
|
||||
"Duplicate notification id: " + notification.id()
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (IllegalArgumentException | DateTimeParseException exception) {
|
||||
throw new IOException("Invalid quest record", exception);
|
||||
}
|
||||
return new QuestState(quests);
|
||||
try {
|
||||
return new QuestState(quests, claims, notifications, bounties, transfers);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IOException("Invalid transfer journal", exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -72,20 +153,74 @@ final class YamlQuestRepository implements QuestRepository {
|
||||
serialized.put("created-at", quest.createdAt().toString());
|
||||
serialized.put("expires-at", quest.expiresAt().toString());
|
||||
serialized.put("status", quest.status().name());
|
||||
List<Map<String, Object>> rewards = new ArrayList<>();
|
||||
for (EscrowItem reward : quest.reward()) {
|
||||
Map<String, Object> serializedReward = new LinkedHashMap<>();
|
||||
serializedReward.put("material", reward.material());
|
||||
serializedReward.put("amount", reward.amount());
|
||||
if (reward.serializedItem() != null) {
|
||||
serializedReward.put("item-data", reward.serializedItem());
|
||||
}
|
||||
rewards.add(serializedReward);
|
||||
}
|
||||
serialized.put("reward", rewards);
|
||||
serialized.put("reward", writeItems(quest.reward()));
|
||||
serializedQuests.add(serialized);
|
||||
}
|
||||
yaml.set("quests", serializedQuests);
|
||||
|
||||
List<Map<String, Object>> serializedBounties = new ArrayList<>();
|
||||
for (Bounty bounty : state.bounties().values()) {
|
||||
Map<String, Object> serialized = new LinkedHashMap<>();
|
||||
serialized.put("id", bounty.id().toString());
|
||||
serialized.put("issuer-id", bounty.issuerId().toString());
|
||||
serialized.put("issuer-name", bounty.issuerName());
|
||||
serialized.put("target-id", bounty.targetId().toString());
|
||||
serialized.put("target-name", bounty.targetName());
|
||||
serialized.put("required-kills", bounty.requiredKills());
|
||||
serialized.put("reward", writeItems(bounty.reward()));
|
||||
serialized.put("created-at", bounty.createdAt().toString());
|
||||
serialized.put("expires-at", bounty.expiresAt().toString());
|
||||
serialized.put("status", bounty.status().name());
|
||||
Map<String, Integer> progress = new LinkedHashMap<>();
|
||||
bounty.progress().forEach((hunter, kills) -> progress.put(hunter.toString(), kills));
|
||||
serialized.put("progress", progress);
|
||||
serialized.put("processed-deaths", bounty.processedDeaths().stream().map(UUID::toString).toList());
|
||||
if (bounty.winnerId() != null) {
|
||||
serialized.put("winner-id", bounty.winnerId().toString());
|
||||
}
|
||||
serializedBounties.add(serialized);
|
||||
}
|
||||
yaml.set("bounties", serializedBounties);
|
||||
|
||||
List<Map<String, Object>> serializedClaims = new ArrayList<>();
|
||||
for (List<QuestClaim> ownerClaims : state.claims().values()) {
|
||||
for (QuestClaim claim : ownerClaims) {
|
||||
Map<String, Object> serialized = new LinkedHashMap<>();
|
||||
serialized.put("id", claim.id().toString());
|
||||
serialized.put("quest-id", claim.questId().toString());
|
||||
serialized.put("owner-id", claim.ownerId().toString());
|
||||
serialized.put("created-at", claim.createdAt().toString());
|
||||
serialized.put("source", claim.source().name());
|
||||
serialized.put("items", writeItems(claim.items()));
|
||||
serializedClaims.add(serialized);
|
||||
}
|
||||
}
|
||||
yaml.set("claims", serializedClaims);
|
||||
|
||||
List<Map<String, Object>> serializedNotifications = new ArrayList<>();
|
||||
for (IssuerNotification notification : state.notifications().values()) {
|
||||
Map<String, Object> serialized = new LinkedHashMap<>();
|
||||
serialized.put("id", notification.id().toString());
|
||||
serialized.put("quest-id", notification.questId().toString());
|
||||
serialized.put("recipient-id", notification.recipientId().toString());
|
||||
serialized.put("message", notification.message());
|
||||
serialized.put("created-at", notification.createdAt().toString());
|
||||
serializedNotifications.add(serialized);
|
||||
}
|
||||
yaml.set("notifications", serializedNotifications);
|
||||
List<Map<String, Object>> serializedTransfers = new ArrayList<>();
|
||||
for (ClaimTransfer transfer : state.transfers().values()) {
|
||||
Map<String, Object> serialized = new LinkedHashMap<>();
|
||||
serialized.put("id", transfer.id().toString());
|
||||
serialized.put("owner-id", transfer.ownerId().toString());
|
||||
if (transfer.claimId() != null) {
|
||||
serialized.put("claim-id", transfer.claimId().toString());
|
||||
}
|
||||
serialized.put("items", writeItems(transfer.items()));
|
||||
serialized.put("status", transfer.status().name());
|
||||
serializedTransfers.add(serialized);
|
||||
}
|
||||
yaml.set("transfers", serializedTransfers);
|
||||
writeAtomically(yaml);
|
||||
}
|
||||
|
||||
@@ -106,24 +241,114 @@ final class YamlQuestRepository implements QuestRepository {
|
||||
}
|
||||
}
|
||||
|
||||
private static List<EscrowItem> readRewards(Object value) {
|
||||
if (!(value instanceof List<?> entries) || entries.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing reward");
|
||||
private static List<Map<?, ?>> readBountyEntries(Object value) {
|
||||
if (value == null) {
|
||||
return List.of(); // Files predating bounties have no such section.
|
||||
}
|
||||
List<EscrowItem> rewards = new ArrayList<>();
|
||||
if (!(value instanceof List<?> entries)) {
|
||||
throw new IllegalArgumentException("Invalid bounties list");
|
||||
}
|
||||
List<Map<?, ?>> records = new ArrayList<>();
|
||||
for (Object entry : entries) {
|
||||
if (!(entry instanceof Map<?, ?> record)) {
|
||||
throw new IllegalArgumentException("Invalid bounty record");
|
||||
}
|
||||
records.add(record);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
private static Map<UUID, Integer> readProgress(Object value) {
|
||||
if (!(value instanceof Map<?, ?> entries)) {
|
||||
throw new IllegalArgumentException("Invalid bounty progress");
|
||||
}
|
||||
Map<UUID, Integer> progress = new LinkedHashMap<>();
|
||||
for (Map.Entry<?, ?> entry : entries.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String hunter)) {
|
||||
throw new IllegalArgumentException("Invalid bounty hunter");
|
||||
}
|
||||
if (progress.put(UUID.fromString(hunter), requiredInteger(entries, hunter)) != null) {
|
||||
throw new IllegalArgumentException("Duplicate bounty hunter id");
|
||||
}
|
||||
}
|
||||
return progress;
|
||||
}
|
||||
|
||||
private static Set<UUID> readDeaths(Object value) {
|
||||
if (!(value instanceof List<?> entries)) {
|
||||
throw new IllegalArgumentException("Invalid bounty processed deaths");
|
||||
}
|
||||
Set<UUID> deaths = new LinkedHashSet<>();
|
||||
for (Object entry : entries) {
|
||||
if (!(entry instanceof String death) || !deaths.add(UUID.fromString(death))) {
|
||||
throw new IllegalArgumentException("Invalid or duplicate bounty death id");
|
||||
}
|
||||
}
|
||||
return deaths;
|
||||
}
|
||||
|
||||
private List<EscrowItem> readItems(Object value, String description) {
|
||||
if (!(value instanceof List<?> entries) || entries.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing " + description);
|
||||
}
|
||||
List<EscrowItem> items = new ArrayList<>();
|
||||
for (Object rawEntry : entries) {
|
||||
if (!(rawEntry instanceof Map<?, ?> entry)) {
|
||||
throw new IllegalArgumentException("Invalid reward");
|
||||
throw new IllegalArgumentException("Invalid " + description);
|
||||
}
|
||||
Object data = entry.get("item-data");
|
||||
if (data != null && !(data instanceof String)) {
|
||||
throw new IllegalArgumentException("Invalid item-data");
|
||||
}
|
||||
rewards.add(new EscrowItem(
|
||||
requiredString(entry, "material"), requiredInteger(entry, "amount"), (String) data
|
||||
));
|
||||
Object customName = entry.get("custom-name");
|
||||
if (customName != null && !(customName instanceof String)) {
|
||||
throw new IllegalArgumentException("Invalid custom-name");
|
||||
}
|
||||
EscrowItem item = new EscrowItem(
|
||||
requiredString(entry, "material"), requiredInteger(entry, "amount"),
|
||||
(String) data, (String) customName
|
||||
);
|
||||
if (item.customName() == null && item.serializedItem() != null) {
|
||||
try {
|
||||
item = java.util.concurrent.CompletableFuture.supplyAsync(item::inferCustomName, itemDecoder).join();
|
||||
} catch (java.util.concurrent.CompletionException | java.util.concurrent.RejectedExecutionException exception) {
|
||||
throw new IllegalArgumentException("Could not decode legacy item on main", exception);
|
||||
}
|
||||
}
|
||||
items.add(item);
|
||||
}
|
||||
return rewards;
|
||||
return items;
|
||||
}
|
||||
|
||||
private static List<Map<String, Object>> writeItems(List<EscrowItem> items) {
|
||||
List<Map<String, Object>> serializedItems = new ArrayList<>();
|
||||
for (EscrowItem item : items) {
|
||||
Map<String, Object> serialized = new LinkedHashMap<>();
|
||||
serialized.put("material", item.material());
|
||||
serialized.put("amount", item.amount());
|
||||
if (item.serializedItem() != null) {
|
||||
serialized.put("item-data", item.serializedItem());
|
||||
}
|
||||
if (item.customName() != null) {
|
||||
serialized.put("custom-name", item.customName());
|
||||
}
|
||||
serializedItems.add(serialized);
|
||||
}
|
||||
return serializedItems;
|
||||
}
|
||||
|
||||
private static QuestClaimSource readClaimSource(Object value, Quest quest) {
|
||||
if (value instanceof String source) {
|
||||
return QuestClaimSource.valueOf(source);
|
||||
}
|
||||
if (value != null) {
|
||||
throw new IllegalArgumentException("Invalid claim source");
|
||||
}
|
||||
if (quest == null || quest.status() == QuestStatus.COMPLETED) {
|
||||
return QuestClaimSource.COMPLETION;
|
||||
}
|
||||
return quest.status() == QuestStatus.EXPIRED
|
||||
? QuestClaimSource.EXPIRATION : QuestClaimSource.CANCELLATION;
|
||||
}
|
||||
|
||||
private static QuestStatus readStatus(Object value) {
|
||||
@@ -146,9 +371,13 @@ final class YamlQuestRepository implements QuestRepository {
|
||||
|
||||
private static int requiredInteger(Map<?, ?> entry, String key) {
|
||||
Object value = entry.get(key);
|
||||
if (!(value instanceof Number number)) {
|
||||
throw new IllegalArgumentException("Missing " + key);
|
||||
if (!(value instanceof Integer) && !(value instanceof Long)) {
|
||||
throw new IllegalArgumentException("Invalid integer: " + key);
|
||||
}
|
||||
return number.intValue();
|
||||
long integer = ((Number) value).longValue();
|
||||
if (integer < Integer.MIN_VALUE || integer > Integer.MAX_VALUE) {
|
||||
throw new IllegalArgumentException("Integer out of range: " + key);
|
||||
}
|
||||
return (int) integer;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ commands:
|
||||
usage: /quests [list|create|complete|cancel|claim]
|
||||
questadmin:
|
||||
description: Administer Spigot Quest Board.
|
||||
usage: /questadmin <createboard|commands>
|
||||
usage: /questadmin <createboard [physical]|commands>
|
||||
permission: spigotquestboard.admin
|
||||
permissions:
|
||||
spigotquestboard.admin:
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.List;
|
||||
import java.util.Queue;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AsyncQuestActionsTest {
|
||||
private static final Instant AT = Instant.parse("2026-01-01T00:00:00Z");
|
||||
private final UUID owner = UUID.randomUUID();
|
||||
private final QueueExecutor worker = new QueueExecutor("worker");
|
||||
private final QueueExecutor main = new QueueExecutor("main");
|
||||
private String lane = "main";
|
||||
private final Player player = mock(Player.class);
|
||||
private final Logger logger = mock(Logger.class);
|
||||
private final QuestClaim claim = new QuestClaim(UUID.randomUUID(), UUID.randomUUID(), owner,
|
||||
List.of(new EscrowItem("DIAMOND", 3, null)), AT, QuestClaimSource.BOUNTY);
|
||||
private final Store store = new Store();
|
||||
private int deliveries;
|
||||
private final QuestClaimInventory inventory = items -> {
|
||||
assertEquals("main", lane);
|
||||
assertEquals(claim.items(), items);
|
||||
return recipient -> {
|
||||
assertEquals("main", lane);
|
||||
deliveries++;
|
||||
return new QuestClaimInventory.Delivery() {
|
||||
@Override public int overflowAmount() { return 0; }
|
||||
@Override public void rollback() { fail("Never roll back after yielding"); }
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
AsyncQuestActionsTest() {
|
||||
when(player.getUniqueId()).thenAnswer(call -> { assertEquals("main", lane); return owner; });
|
||||
when(player.isOnline()).thenAnswer(call -> { assertEquals("main", lane); return true; });
|
||||
when(player.isDead()).thenAnswer(call -> { assertEquals("main", lane); return false; });
|
||||
doAnswer(call -> { assertEquals("main", lane); return null; }).when(player).sendMessage(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistsBeforeDeliveryAndRepeatCollectionCannotDeliverAgain() throws Exception {
|
||||
QuestService service = new QuestService(store);
|
||||
AsyncQuestActions actions = new AsyncQuestActions(service, worker, main, inventory, logger);
|
||||
actions.collect(player);
|
||||
actions.collect(player);
|
||||
assertEquals(0, deliveries);
|
||||
assertEquals(0, store.saves);
|
||||
worker.drain();
|
||||
assertTrue(store.saves > 0, "claim transfer must be saved before main delivery");
|
||||
assertEquals(List.of(claim), service.claimsFor(owner), "reservation retains pending claim");
|
||||
main.drain();
|
||||
assertEquals(1, deliveries);
|
||||
worker.drain();
|
||||
assertTrue(service.claimsFor(owner).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void journalSurvivesReloadAndUncertainDeliveryIsNotRepeated(@org.junit.jupiter.api.io.TempDir java.nio.file.Path dir) throws Exception {
|
||||
YamlQuestRepository repository = new YamlQuestRepository(dir.resolve("quests.yml"));
|
||||
repository.save(store.state);
|
||||
QuestService service = new QuestService(repository);
|
||||
AsyncQuestActions actions = new AsyncQuestActions(service, worker, main, inventory, logger);
|
||||
actions.collect(player);
|
||||
worker.drain(); // Simulated crash: reserved durably, unknown whether main delivery happened.
|
||||
QuestService reloaded = new QuestService(repository);
|
||||
assertEquals(1, reloaded.state().transfers().size(), "reservation must survive the crash");
|
||||
QueueExecutor restartedMain = new QueueExecutor("main");
|
||||
AsyncQuestActions restarted = new AsyncQuestActions(reloaded, worker, restartedMain, inventory, logger);
|
||||
restarted.collect(player);
|
||||
worker.drain();
|
||||
restartedMain.drain();
|
||||
assertEquals(0, deliveries);
|
||||
assertEquals(1, reloaded.state().transfers().size());
|
||||
assertEquals(List.of(claim), reloaded.claimsFor(owner));
|
||||
assertTrue(reloaded.reserveClaims(owner).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void acknowledgementFailureRetriesWithoutRollingBackOrRedelivering() throws Exception {
|
||||
QuestService service = new QuestService(store);
|
||||
AsyncQuestActions actions = new AsyncQuestActions(service, worker, main, inventory, logger);
|
||||
actions.collect(player);
|
||||
worker.drain();
|
||||
store.failures = 1;
|
||||
main.drain();
|
||||
assertEquals(1, deliveries);
|
||||
worker.drain();
|
||||
assertTrue(service.claimsFor(owner).isEmpty(), "ack should retry after a transient save failure");
|
||||
actions.collect(player);
|
||||
worker.drain();
|
||||
main.drain();
|
||||
assertEquals(1, deliveries);
|
||||
}
|
||||
|
||||
@Test
|
||||
void creationRemovesOnMainSavesOnWorkerAndFeedbackRunsOnMain() throws Exception {
|
||||
QuestService service = new QuestService(store);
|
||||
Inputs inputs = new Inputs();
|
||||
AsyncQuestActions actions = actions(service, inputs);
|
||||
actions.postBounty(player, UUID.randomUUID(), "Target", 2, AT);
|
||||
assertEquals(1, inputs.removals);
|
||||
assertTrue(service.state().bounties().isEmpty());
|
||||
worker.drain();
|
||||
assertEquals(1, service.state().bounties().size());
|
||||
assertEquals(claim.items(), service.state().bounties().values().iterator().next().reward());
|
||||
main.drain();
|
||||
assertEquals(0, inputs.returns);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedCreationReturnsExactInputButLogoutKeepsDurablePendingRecovery() throws Exception {
|
||||
QuestService service = new QuestService(store);
|
||||
Inputs inputs = new Inputs();
|
||||
AsyncQuestActions actions = actions(service, inputs);
|
||||
actions.create(player, "stone", 4, AT);
|
||||
store.failures = 1;
|
||||
worker.drain();
|
||||
when(player.isOnline()).thenReturn(false);
|
||||
main.drain();
|
||||
worker.drain();
|
||||
assertTrue(service.state().quests().isEmpty());
|
||||
assertEquals(0, inputs.returns, "must not give items to a logged-out Player");
|
||||
assertEquals(1, service.state().transfers().size());
|
||||
assertEquals(claim.items(), service.state().transfers().values().iterator().next().items());
|
||||
assertEquals(ClaimTransfer.Status.PENDING, service.state().transfers().values().iterator().next().status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validationHappensBeforeRewardRemoval() throws Exception {
|
||||
QuestService service = new QuestService(store);
|
||||
Inputs inputs = new Inputs();
|
||||
AsyncQuestActions actions = actions(service, inputs);
|
||||
actions.postBounty(player, UUID.randomUUID(), "Target", 0, AT);
|
||||
actions.create(player, "stone", 0, AT);
|
||||
actions.create(player, "no-such-item", 3, AT);
|
||||
worker.drain();
|
||||
main.drain();
|
||||
assertEquals(0, inputs.removals);
|
||||
assertEquals(0, store.saves);
|
||||
verify(player, atLeastOnce()).sendMessage(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void safeInputReturnRestoresOriginalSlotOnlyWhileItIsUnchanged() {
|
||||
org.bukkit.inventory.PlayerInventory slots = mock(org.bukkit.inventory.PlayerInventory.class);
|
||||
org.bukkit.inventory.ItemStack held = mock(org.bukkit.inventory.ItemStack.class);
|
||||
org.bukkit.inventory.ItemStack changed = mock(org.bukkit.inventory.ItemStack.class);
|
||||
when(changed.getType()).thenReturn(org.bukkit.Material.STONE);
|
||||
when(held.getType()).thenReturn(org.bukkit.Material.DIAMOND);
|
||||
when(held.getAmount()).thenReturn(3);
|
||||
when(held.clone()).thenReturn(held);
|
||||
when(player.getInventory()).thenReturn(slots);
|
||||
when(slots.getHeldItemSlot()).thenReturn(2);
|
||||
when(slots.getItemInMainHand()).thenReturn(held);
|
||||
try (org.mockito.MockedStatic<EscrowItem> serialization = mockStatic(EscrowItem.class)) {
|
||||
serialization.when(() -> EscrowItem.fromItemStack(held)).thenReturn(claim.items().getFirst());
|
||||
BukkitQuestInputInventory adapter = new BukkitQuestInputInventory(inventory);
|
||||
QuestInputInventory.Reservation first = adapter.removeReward(player);
|
||||
first.returnTo(player);
|
||||
verify(slots).setItem(2, held);
|
||||
assertEquals(0, deliveries);
|
||||
|
||||
QuestInputInventory.Reservation second = adapter.removeReward(player);
|
||||
when(slots.getItem(2)).thenReturn(changed); // unrelated pickup/move while the worker saves
|
||||
second.returnTo(player);
|
||||
assertEquals(1, deliveries, "return exact reward via safe additive delivery, not old snapshot");
|
||||
verify(slots, times(1)).setItem(2, held);
|
||||
verify(slots, never()).setStorageContents(org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyCompletionKeepsIssuerDeliveryAndAwardsExactRewardOffTick() throws Exception {
|
||||
store.state = QuestState.empty();
|
||||
QuestService service = new QuestService(store);
|
||||
Inputs inputs = new Inputs();
|
||||
AsyncQuestActions actions = actions(service, inputs);
|
||||
actions.create(player, "stone", 4, AT);
|
||||
drain();
|
||||
UUID id = service.activeQuests(AT).getFirst().id();
|
||||
actions.complete(player, id.toString(), AT.plusSeconds(1));
|
||||
drain();
|
||||
assertTrue(service.activeQuests(AT).isEmpty());
|
||||
assertEquals(1, deliveries, "completion reward is automatically delivered, not all issuer claims");
|
||||
assertEquals(List.of(new EscrowItem("STONE", 4, null)), service.claimsFor(owner).getFirst().items());
|
||||
assertEquals(QuestClaimSource.COMPLETION, service.claimsFor(owner).getFirst().source());
|
||||
assertTrue(service.state().transfers().isEmpty());
|
||||
assertEquals(0, inputs.returns);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deathCancellationAndExpiryUseWorkerAndNotificationsUseMain() throws Exception {
|
||||
store.state = QuestState.empty();
|
||||
QuestService service = new QuestService(store);
|
||||
AsyncQuestActions actions = actions(service, new Inputs());
|
||||
UUID target = UUID.randomUUID();
|
||||
UUID hunter = UUID.randomUUID();
|
||||
actions.postBounty(player, target, "Target", 1, AT);
|
||||
drain();
|
||||
UUID death = UUID.randomUUID();
|
||||
actions.death(death, target, hunter, AT.plusSeconds(1));
|
||||
actions.death(death, target, hunter, AT.plusSeconds(1));
|
||||
drain();
|
||||
assertEquals(1, service.claimsFor(hunter).size());
|
||||
assertEquals(QuestClaimSource.BOUNTY, service.claimsFor(hunter).getFirst().source());
|
||||
assertTrue(service.activeBounties(AT).isEmpty());
|
||||
actions.postBounty(player, target, "Target", 3, AT);
|
||||
drain();
|
||||
actions.cancelBounty(player, service.activeBounties(AT).getFirst().id().toString(), AT.plusSeconds(2));
|
||||
drain();
|
||||
assertEquals(QuestClaimSource.CANCELLATION, service.claimsFor(owner).getFirst().source());
|
||||
actions.create(player, "stone", 4, AT);
|
||||
drain();
|
||||
actions.cancel(player, service.activeQuests(AT).getFirst().id().toString(), AT.plusSeconds(2));
|
||||
drain();
|
||||
assertEquals(2, service.claimsFor(owner).size());
|
||||
actions.postBounty(player, target, "Target", 3, AT);
|
||||
actions.create(player, "stone", 4, AT);
|
||||
drain();
|
||||
actions.expire(AT.plusSeconds(7 * 86400));
|
||||
drain();
|
||||
assertEquals(4, service.claimsFor(owner).size());
|
||||
assertEquals(2, service.pendingNotifications(owner).size());
|
||||
actions.joined(player);
|
||||
drain();
|
||||
assertTrue(service.pendingNotifications(owner).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedReservationDoesNotTouchInventoryAndReportsFailure() throws Exception {
|
||||
QuestService service = new QuestService(store);
|
||||
AsyncQuestActions actions = new AsyncQuestActions(service, worker, main, inventory, logger);
|
||||
store.failures = 1;
|
||||
actions.collect(player);
|
||||
drain();
|
||||
assertEquals(0, deliveries);
|
||||
assertEquals(List.of(claim), service.claimsFor(owner));
|
||||
assertTrue(service.state().transfers().isEmpty());
|
||||
verify(player).sendMessage("Could not reserve quest claims; no items were delivered.");
|
||||
verify(logger, atLeastOnce()).log(eq(java.util.logging.Level.WARNING), anyString(), any(Exception.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void offlineAndDeadRecipientsReleaseWithoutDeliveryAndReleaseRetries() throws Exception {
|
||||
QuestService service = new QuestService(store);
|
||||
AsyncQuestActions actions = new AsyncQuestActions(service, worker, main, inventory, logger);
|
||||
actions.collect(player);
|
||||
worker.drain();
|
||||
store.failures = 1;
|
||||
when(player.isDead()).thenReturn(true);
|
||||
drain();
|
||||
assertEquals(0, deliveries);
|
||||
assertTrue(service.state().transfers().isEmpty());
|
||||
when(player.isDead()).thenReturn(false);
|
||||
actions.collect(player);
|
||||
worker.drain();
|
||||
when(player.isOnline()).thenReturn(false);
|
||||
drain();
|
||||
assertEquals(0, deliveries);
|
||||
assertEquals(List.of(claim), service.claimsFor(owner));
|
||||
assertTrue(service.state().transfers().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistentAckFailureIsQuarantinedUntilKnownOutcomeCanBeSaved() throws Exception {
|
||||
QuestService service = new QuestService(store);
|
||||
AsyncQuestActions actions = new AsyncQuestActions(service, worker, main, inventory, logger);
|
||||
actions.collect(player);
|
||||
worker.drain();
|
||||
store.failures = 100;
|
||||
drain();
|
||||
actions.collect(player);
|
||||
drain();
|
||||
assertEquals(1, deliveries);
|
||||
assertEquals(1, service.state().transfers().size());
|
||||
assertEquals(List.of(claim), service.claimsFor(owner));
|
||||
store.failures = 0;
|
||||
actions.joined(player);
|
||||
drain();
|
||||
assertTrue(service.state().transfers().isEmpty());
|
||||
assertTrue(service.claimsFor(owner).isEmpty());
|
||||
assertEquals(1, deliveries);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedDeliveryReleasesButFeedbackFailureAfterDeliveryNeverReleases() throws Exception {
|
||||
QuestService service = new QuestService(store);
|
||||
QuestClaimInventory broken = items -> recipient -> { throw new IllegalStateException("rolled back same callback"); };
|
||||
AsyncQuestActions failed = new AsyncQuestActions(service, worker, main, broken, logger);
|
||||
failed.collect(player);
|
||||
drain();
|
||||
assertTrue(service.state().transfers().isEmpty());
|
||||
assertEquals(List.of(claim), service.claimsFor(owner));
|
||||
doThrow(new IllegalStateException("message failure")).when(player).sendMessage(anyString());
|
||||
AsyncQuestActions success = new AsyncQuestActions(service, worker, main, inventory, logger);
|
||||
success.collect(player);
|
||||
drain();
|
||||
assertEquals(1, deliveries);
|
||||
assertTrue(service.claimsFor(owner).isEmpty());
|
||||
assertTrue(service.state().transfers().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unrelatedMutationDuringDeliveryCannotDiscardReservationOrBeRolledBack() throws Exception {
|
||||
QuestService service = new QuestService(store);
|
||||
AsyncQuestActions actions = actions(service, new Inputs());
|
||||
actions.collect(player);
|
||||
actions.postBounty(player, UUID.randomUUID(), "Target", 3, AT);
|
||||
worker.drain();
|
||||
assertEquals(1, service.state().transfers().size());
|
||||
assertEquals(1, service.state().bounties().size());
|
||||
main.drain();
|
||||
worker.drain();
|
||||
assertEquals(1, service.state().bounties().size());
|
||||
assertTrue(service.state().transfers().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completionRacesReturnOnlyLosingDeliveryAndKeepOneReward() throws Exception {
|
||||
store.state = QuestState.empty();
|
||||
QuestService service = new QuestService(store);
|
||||
Inputs inputs = new Inputs();
|
||||
AsyncQuestActions actions = actions(service, inputs);
|
||||
actions.create(player, "stone", 4, AT);
|
||||
drain();
|
||||
String id = service.activeQuests(AT).getFirst().id().toString();
|
||||
actions.complete(player, id, AT.plusSeconds(1));
|
||||
actions.complete(player, id, AT.plusSeconds(1));
|
||||
drain();
|
||||
assertEquals(1, deliveries);
|
||||
assertEquals(1, inputs.returns);
|
||||
assertEquals(1, service.claimsFor(owner).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidTransferJournalFailsClosedInsteadOfReleasingClaims(@org.junit.jupiter.api.io.TempDir java.nio.file.Path dir) throws Exception {
|
||||
YamlQuestRepository repository = new YamlQuestRepository(dir.resolve("quests.yml"));
|
||||
repository.save(store.state);
|
||||
String corrupted = java.nio.file.Files.readString(dir.resolve("quests.yml"))
|
||||
.replace("transfers: []", "transfers:\n- id: " + UUID.randomUUID() + "\n owner-id: " + UUID.randomUUID()
|
||||
+ "\n claim-id: " + claim.id() + "\n status: RESERVED\n items:\n - material: DIAMOND\n amount: 3");
|
||||
java.nio.file.Files.writeString(dir.resolve("quests.yml"), corrupted);
|
||||
assertThrows(IOException.class, repository::load, "a wrong-owner journal must not become collectible");
|
||||
}
|
||||
|
||||
@Test
|
||||
void earnedBountyCollectionFeedbackIdentifiesRewardAndQuest() throws Exception {
|
||||
AsyncQuestActions actions = new AsyncQuestActions(new QuestService(store), worker, main, inventory, logger);
|
||||
actions.collect(player);
|
||||
drain();
|
||||
verify(player).sendMessage("Collected earned bounty reward from bounty " + claim.questId() + ".");
|
||||
}
|
||||
|
||||
@Test
|
||||
void workerReloadDispatchesLegacyBukkitItemDecodingToMain(@org.junit.jupiter.api.io.TempDir java.nio.file.Path dir) throws Exception {
|
||||
java.nio.file.Path path = dir.resolve("quests.yml");
|
||||
EscrowItem exact = new EscrowItem("DIAMOND", 3, "AA==");
|
||||
QuestClaim oldClaim = new QuestClaim(claim.id(), claim.questId(), owner, List.of(exact), AT, QuestClaimSource.BOUNTY);
|
||||
new YamlQuestRepository(path).save(new QuestState(java.util.Map.of(), java.util.Map.of(owner, List.of(oldClaim)), java.util.Map.of()));
|
||||
Executor dispatchMain = command -> {
|
||||
assertEquals("worker", lane);
|
||||
lane = "main";
|
||||
try { command.run(); } finally { lane = "worker"; }
|
||||
};
|
||||
org.bukkit.inventory.ItemStack stack = mock(org.bukkit.inventory.ItemStack.class);
|
||||
when(stack.getType()).thenReturn(org.bukkit.Material.DIAMOND);
|
||||
when(stack.getAmount()).thenReturn(3);
|
||||
try (org.mockito.MockedStatic<org.bukkit.inventory.ItemStack> decoding = mockStatic(org.bukkit.inventory.ItemStack.class)) {
|
||||
decoding.when(() -> org.bukkit.inventory.ItemStack.deserializeBytes(any(byte[].class))).thenAnswer(call -> {
|
||||
assertEquals("main", lane, "Bukkit deserialization must not run on the I/O worker");
|
||||
return stack;
|
||||
});
|
||||
lane = "worker";
|
||||
try {
|
||||
assertEquals(List.of(oldClaim), new YamlQuestRepository(path, dispatchMain).load().claims().get(owner));
|
||||
} finally {
|
||||
lane = "main";
|
||||
}
|
||||
decoding.verify(() -> org.bukkit.inventory.ItemStack.deserializeBytes(any(byte[].class)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void queuedDeathsPreserveSubmissionOrderRatherThanMapIterationOrder() throws Exception {
|
||||
store.state = QuestState.empty();
|
||||
QuestService service = new QuestService(store);
|
||||
AsyncQuestActions actions = actions(service, new Inputs());
|
||||
UUID earlierHunter = UUID.randomUUID();
|
||||
UUID laterHunter = UUID.randomUUID();
|
||||
UUID eventA = UUID.randomUUID();
|
||||
UUID eventB = UUID.randomUUID();
|
||||
for (int round = 0; round < 2; round++) {
|
||||
UUID target = UUID.randomUUID();
|
||||
actions.postBounty(player, target, "Target", 1, AT);
|
||||
drain();
|
||||
actions.death(round == 0 ? eventA : eventB, target, earlierHunter, AT.plusSeconds(1));
|
||||
actions.death(round == 0 ? eventB : eventA, target, laterHunter, AT.plusSeconds(2));
|
||||
drain();
|
||||
assertEquals(round + 1, service.claimsFor(earlierHunter).size(), "first qualifying event wins");
|
||||
assertTrue(service.claimsFor(laterHunter).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void logoutRecoveryReloadsAndCollectsOnce(@org.junit.jupiter.api.io.TempDir java.nio.file.Path dir) throws Exception {
|
||||
YamlQuestRepository disk = new YamlQuestRepository(dir.resolve("quests.yml"));
|
||||
QuestRepository failingFirstSave = new QuestRepository() {
|
||||
private boolean fail = true;
|
||||
@Override public QuestState load() throws IOException { return disk.load(); }
|
||||
@Override public void save(QuestState state) throws IOException {
|
||||
assertEquals("worker", lane);
|
||||
if (fail) { fail = false; throw new IOException("controlled create failure"); }
|
||||
disk.save(state);
|
||||
}
|
||||
};
|
||||
QuestService service = new QuestService(failingFirstSave);
|
||||
AsyncQuestActions actions = actions(service, new Inputs());
|
||||
actions.create(player, "stone", 4, AT);
|
||||
worker.drain();
|
||||
when(player.isOnline()).thenReturn(false);
|
||||
drain();
|
||||
QuestService reloaded = new QuestService(disk);
|
||||
assertEquals(1, reloaded.state().transfers().size());
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
AsyncQuestActions restarted = new AsyncQuestActions(reloaded, worker, main, inventory, logger);
|
||||
restarted.collect(player);
|
||||
drain();
|
||||
restarted.collect(player);
|
||||
drain();
|
||||
assertEquals(1, deliveries);
|
||||
assertTrue(new QuestService(disk).state().transfers().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingInputRecoverySurvivesRepeatedSaveFailureInMemoryUntilNextAction() throws Exception {
|
||||
store.state = QuestState.empty();
|
||||
QuestService service = new QuestService(store);
|
||||
AsyncQuestActions actions = actions(service, new Inputs());
|
||||
actions.postBounty(player, UUID.randomUUID(), "Target", 2, AT);
|
||||
store.failures = 100;
|
||||
worker.drain();
|
||||
when(player.isOnline()).thenReturn(false);
|
||||
drain();
|
||||
assertTrue(service.state().transfers().isEmpty());
|
||||
store.failures = 0;
|
||||
actions.expire(AT.plusSeconds(1));
|
||||
drain();
|
||||
assertEquals(1, service.state().transfers().size(), "return must not be forgotten after retries exhaust");
|
||||
assertTrue(service.state().bounties().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shutdownRecoversRetainedInputWithoutAnotherPlayerAction() throws Exception {
|
||||
store.state = QuestState.empty();
|
||||
QuestService service = new QuestService(store);
|
||||
AsyncQuestActions actions = actions(service, new Inputs());
|
||||
actions.postBounty(player, UUID.randomUUID(), "Target", 2, AT);
|
||||
store.failures = 100;
|
||||
worker.drain();
|
||||
when(player.isOnline()).thenReturn(false);
|
||||
drain();
|
||||
assertTrue(service.state().transfers().isEmpty());
|
||||
store.failures = 0;
|
||||
worker.execute(() -> assertTrue(actions.shutdownRecovery()));
|
||||
worker.drain();
|
||||
QuestService reloaded = new QuestService(store);
|
||||
assertEquals(1, reloaded.state().transfers().size(), "disable must recover sole copy of removed items");
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
AsyncQuestActions restarted = new AsyncQuestActions(reloaded, worker, main, inventory, logger);
|
||||
restarted.collect(player);
|
||||
drain();
|
||||
restarted.collect(player);
|
||||
drain();
|
||||
assertEquals(1, deliveries);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mainExecutorRejectionReleasesUndeliveredReservations() throws Exception {
|
||||
QuestService service = new QuestService(store);
|
||||
Executor rejectedMain = command -> { throw new java.util.concurrent.RejectedExecutionException("stopping"); };
|
||||
AsyncQuestActions actions = new AsyncQuestActions(service, worker, rejectedMain, inventory, logger);
|
||||
actions.collect(player);
|
||||
worker.drain();
|
||||
assertEquals(0, deliveries);
|
||||
assertTrue(service.state().transfers().isEmpty());
|
||||
assertEquals(List.of(claim), service.claimsFor(owner));
|
||||
}
|
||||
|
||||
@Test
|
||||
void workerRejectionAfterDeliveryKeepsKnownAckAndNeverRollsBack() throws Exception {
|
||||
QuestService service = new QuestService(store);
|
||||
boolean[] reject = {false};
|
||||
Executor stoppable = command -> {
|
||||
if (reject[0]) { throw new java.util.concurrent.RejectedExecutionException("stopping"); }
|
||||
worker.execute(command);
|
||||
};
|
||||
AsyncQuestActions actions = new AsyncQuestActions(service, stoppable, main, inventory, logger);
|
||||
actions.collect(player);
|
||||
worker.drain();
|
||||
reject[0] = true;
|
||||
main.drain();
|
||||
assertEquals(1, deliveries);
|
||||
assertEquals(1, service.state().transfers().size());
|
||||
reject[0] = false;
|
||||
actions.collect(player);
|
||||
drain();
|
||||
assertEquals(1, deliveries);
|
||||
assertTrue(service.claimsFor(owner).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bukkitPartialDeliveryRollsBackWithinCallbackBeforeWorkerReleases() throws Exception {
|
||||
EscrowItem exact = new EscrowItem("DIAMOND", 3, "AA==");
|
||||
QuestClaim exactClaim = new QuestClaim(claim.id(), claim.questId(), owner, List.of(exact), AT, QuestClaimSource.BOUNTY);
|
||||
store.state = new QuestState(java.util.Map.of(), java.util.Map.of(owner, List.of(exactClaim)), java.util.Map.of());
|
||||
QuestService service = new QuestService(store);
|
||||
org.bukkit.inventory.PlayerInventory slots = mock(org.bukkit.inventory.PlayerInventory.class);
|
||||
org.bukkit.inventory.ItemStack stack = mock(org.bukkit.inventory.ItemStack.class);
|
||||
org.bukkit.World world = mock(org.bukkit.World.class);
|
||||
org.bukkit.Location location = mock(org.bukkit.Location.class);
|
||||
org.bukkit.entity.Item dropped = mock(org.bukkit.entity.Item.class);
|
||||
when(player.getInventory()).thenAnswer(call -> { assertEquals("main", lane); return slots; });
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(player.getLocation()).thenReturn(location);
|
||||
when(slots.getStorageContents()).thenReturn(new org.bukkit.inventory.ItemStack[36]);
|
||||
when(stack.clone()).thenReturn(stack);
|
||||
when(stack.getType()).thenReturn(org.bukkit.Material.DIAMOND);
|
||||
when(stack.getAmount()).thenReturn(3);
|
||||
when(slots.addItem(stack)).thenReturn(new java.util.HashMap<>(java.util.Map.of(0, stack)));
|
||||
when(world.dropItem(location, stack)).thenReturn(dropped);
|
||||
doThrow(new IllegalStateException("controlled owner failure")).when(dropped).setOwner(owner);
|
||||
try (org.mockito.MockedStatic<org.bukkit.inventory.ItemStack> decoding = mockStatic(org.bukkit.inventory.ItemStack.class)) {
|
||||
decoding.when(() -> org.bukkit.inventory.ItemStack.deserializeBytes(any(byte[].class))).thenReturn(stack);
|
||||
AsyncQuestActions actions = new AsyncQuestActions(service, worker, main, new BukkitQuestClaimInventory(), logger);
|
||||
actions.collect(player);
|
||||
worker.drain();
|
||||
main.drain();
|
||||
verify(slots).setStorageContents(any(org.bukkit.inventory.ItemStack[].class));
|
||||
verify(dropped).remove();
|
||||
assertEquals(1, service.state().transfers().size(), "still reserved until worker saves release");
|
||||
worker.drain();
|
||||
assertTrue(service.state().transfers().isEmpty());
|
||||
assertEquals(List.of(exactClaim), service.claimsFor(owner));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void creationThenDeathsBeforeWorkerRunsStillHonorsActorOrderAndRetriesFailedDeath() throws Exception {
|
||||
store.state = QuestState.empty();
|
||||
QuestService service = new QuestService(store);
|
||||
AsyncQuestActions actions = actions(service, new Inputs());
|
||||
UUID target = UUID.randomUUID();
|
||||
UUID hunter = UUID.randomUUID();
|
||||
actions.postBounty(player, target, "Target", 1, AT);
|
||||
actions.death(UUID.randomUUID(), target, hunter, AT.plusSeconds(1));
|
||||
drain();
|
||||
assertEquals(1, service.claimsFor(hunter).size());
|
||||
actions.postBounty(player, target, "Target", 1, AT.plusSeconds(2));
|
||||
drain();
|
||||
store.failures = 1;
|
||||
actions.death(UUID.randomUUID(), target, hunter, AT.plusSeconds(3));
|
||||
actions.death(UUID.randomUUID(), target, UUID.randomUUID(), AT.plusSeconds(4));
|
||||
drain();
|
||||
assertEquals(2, service.claimsFor(hunter).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiryCannotOvertakeAnEarlierDeathWhosePersistenceRetriesAreExhausted() throws Exception {
|
||||
store.state = QuestState.empty();
|
||||
QuestService service = new QuestService(store);
|
||||
AsyncQuestActions actions = actions(service, new Inputs());
|
||||
UUID target = UUID.randomUUID();
|
||||
UUID hunter = UUID.randomUUID();
|
||||
actions.postBounty(player, target, "Target", 1, AT);
|
||||
drain();
|
||||
store.failures = 6;
|
||||
actions.death(UUID.randomUUID(), target, hunter, AT.plusSeconds(1));
|
||||
actions.expire(AT.plusSeconds(7 * 86400));
|
||||
drain();
|
||||
actions.expire(AT.plusSeconds(7 * 86400));
|
||||
drain();
|
||||
assertEquals(1, service.claimsFor(hunter).size(), "earlier qualifying death must settle before expiry");
|
||||
assertTrue(service.claimsFor(owner).isEmpty(), "expiry cannot refund a reward already earned by a queued death");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recurringJoinReminderAndEmptyCollectionKeepLegacyFeedback() throws Exception {
|
||||
QuestService service = new QuestService(store);
|
||||
AsyncQuestActions actions = new AsyncQuestActions(service, worker, main, inventory, logger);
|
||||
actions.joined(player);
|
||||
actions.joined(player);
|
||||
drain();
|
||||
verify(player, times(2)).sendMessage("You have items waiting to be claimed. Visit the quest board to collect them.");
|
||||
actions.collect(player);
|
||||
drain();
|
||||
clearInvocations(player);
|
||||
actions.joined(player);
|
||||
actions.collect(player);
|
||||
drain();
|
||||
verify(player, never()).sendMessage("You have items waiting to be claimed. Visit the quest board to collect them.");
|
||||
verify(player).sendMessage("You have no pending quest claims.");
|
||||
}
|
||||
|
||||
private void drain() {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
worker.drain();
|
||||
main.drain();
|
||||
}
|
||||
}
|
||||
|
||||
private AsyncQuestActions actions(QuestService service, Inputs inputs) {
|
||||
when(player.getName()).thenAnswer(call -> { assertEquals("main", lane); return "Issuer"; });
|
||||
ItemMaterialCatalog materials = new ItemMaterialCatalog() {
|
||||
@Override public java.util.Optional<String> normalizeItem(String input) {
|
||||
assertEquals("main", lane);
|
||||
return "stone".equalsIgnoreCase(input) ? java.util.Optional.of("STONE") : java.util.Optional.empty();
|
||||
}
|
||||
@Override public List<String> suggest(String prefix) { return List.of("STONE"); }
|
||||
};
|
||||
return new AsyncQuestActions(service, worker, main, inputs, inventory, materials, id -> null, logger);
|
||||
}
|
||||
|
||||
private final class Inputs implements QuestInputInventory {
|
||||
private int removals;
|
||||
private int returns;
|
||||
@Override public Reservation removeReward(Player recipient) {
|
||||
assertEquals("main", lane);
|
||||
removals++;
|
||||
return new Reservation() {
|
||||
@Override public List<EscrowItem> items() { return claim.items(); }
|
||||
@Override public void returnTo(Player recipient) { assertEquals("main", lane); returns++; }
|
||||
};
|
||||
}
|
||||
@Override public Reservation removeDelivery(Player recipient, String material, int amount) {
|
||||
assertEquals("main", lane);
|
||||
removals++;
|
||||
return new Reservation() {
|
||||
@Override public List<EscrowItem> items() { return List.of(new EscrowItem(material, amount, null)); }
|
||||
@Override public void returnTo(Player recipient) { assertEquals("main", lane); returns++; }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private final class Store implements QuestRepository {
|
||||
private QuestState state = new QuestState(java.util.Map.of(), java.util.Map.of(owner, List.of(claim)), java.util.Map.of());
|
||||
private int saves;
|
||||
private int failures;
|
||||
@Override public QuestState load() { return state; }
|
||||
@Override public void save(QuestState next) throws IOException {
|
||||
assertEquals("worker", lane);
|
||||
saves++;
|
||||
if (failures > 0) {
|
||||
failures--;
|
||||
throw new IOException("controlled persistence failure");
|
||||
}
|
||||
state = next;
|
||||
}
|
||||
}
|
||||
|
||||
private final class QueueExecutor implements Executor {
|
||||
private final String name;
|
||||
private final Queue<Runnable> tasks = new ArrayDeque<>();
|
||||
QueueExecutor(String name) { this.name = name; }
|
||||
@Override public void execute(Runnable command) { tasks.add(command); }
|
||||
void drain() {
|
||||
int count = 0;
|
||||
while (!tasks.isEmpty()) {
|
||||
assertTrue(count++ < 100, "unbounded retry loop");
|
||||
lane = name;
|
||||
try { tasks.remove().run(); } finally { lane = "main"; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,40 @@ final class BoardRegistryTest {
|
||||
assertEquals(Set.of(BOARD), repository.saved.boards());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registersManyLocationsInOneRepositorySave() throws IOException {
|
||||
RegisteredBoard second = new RegisteredBoard(
|
||||
new BoardId(BOARD.id().worldId(), 2, 64, 2), "world"
|
||||
);
|
||||
RecordingRepository repository = new RecordingRepository(BoardState.empty());
|
||||
BoardRegistry registry = new BoardRegistry(repository);
|
||||
|
||||
assertEquals(
|
||||
BoardRegistrationResult.CREATED,
|
||||
registry.registerAll(Set.of(BOARD, second))
|
||||
);
|
||||
|
||||
assertEquals(1, repository.saveCount);
|
||||
assertEquals(Set.of(BOARD, second), repository.saved.boards());
|
||||
assertTrue(registry.contains(BOARD.id()));
|
||||
assertTrue(registry.contains(second.id()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateInBatchPublishesAndPersistsNothing() throws IOException {
|
||||
RegisteredBoard conflict = new RegisteredBoard(BOARD.id(), "renamed-world");
|
||||
RecordingRepository repository = new RecordingRepository(new BoardState(Set.of(BOARD)));
|
||||
BoardRegistry registry = new BoardRegistry(repository);
|
||||
|
||||
assertEquals(
|
||||
BoardRegistrationResult.ALREADY_REGISTERED,
|
||||
registry.registerAll(Set.of(conflict))
|
||||
);
|
||||
|
||||
assertEquals(0, repository.saveCount);
|
||||
assertEquals(1, registry.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateDoesNotWrite() throws IOException {
|
||||
RecordingRepository repository = new RecordingRepository(new BoardState(Set.of(BOARD)));
|
||||
@@ -35,6 +69,24 @@ final class BoardRegistryTest {
|
||||
assertEquals(0, repository.saveCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
void proximityUsesSameWorldAndAcceptsExactFiveBlockBoundary() throws Exception {
|
||||
RecordingRepository repository = new RecordingRepository(new BoardState(Set.of(BOARD)));
|
||||
BoardRegistry registry = new BoardRegistry(repository);
|
||||
|
||||
assertTrue(registry.isWithin(
|
||||
BOARD.id().worldId(), BOARD.id().x() + 3.0, BOARD.id().y() + 4.0,
|
||||
BOARD.id().z(), 5.0
|
||||
));
|
||||
assertFalse(registry.isWithin(
|
||||
BOARD.id().worldId(), BOARD.id().x() + 3.01, BOARD.id().y() + 4.0,
|
||||
BOARD.id().z(), 5.0
|
||||
));
|
||||
assertFalse(registry.isWithin(
|
||||
UUID.randomUUID(), BOARD.id().x(), BOARD.id().y(), BOARD.id().z(), 5.0
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedPersistenceDoesNotPublishBoard() throws Exception {
|
||||
BoardRepository repository = new BoardRepository() {
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class BountyBoardIntegrationTest {
|
||||
private static final Instant AT = Instant.parse("2026-01-01T00:00:00Z");
|
||||
private Instant now = AT;
|
||||
private final Clock clock = new Clock() {
|
||||
@Override public Instant instant() { return now; }
|
||||
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
|
||||
@Override public Clock withZone(ZoneId zone) { return Clock.fixed(now, zone); }
|
||||
};
|
||||
private String lane = "main";
|
||||
private final Lane worker = new Lane("worker");
|
||||
private final Lane main = new Lane("main");
|
||||
private final List<EscrowItem> reward = List.of(new EscrowItem("DIAMOND", 3, "exact-data", "Hunter's Prize"));
|
||||
private final List<List<EscrowItem>> delivered = new ArrayList<>();
|
||||
private int removed;
|
||||
private final QuestInputInventory inputs = new QuestInputInventory() {
|
||||
@Override public Reservation removeReward(Player player) {
|
||||
assertEquals("main", lane);
|
||||
removed++;
|
||||
return new Reservation() {
|
||||
@Override public List<EscrowItem> items() { return reward; }
|
||||
@Override public void returnTo(Player recipient) { assertEquals("main", lane); removed--; }
|
||||
};
|
||||
}
|
||||
@Override public Reservation removeDelivery(Player player, String material, int amount) {
|
||||
assertEquals("main", lane);
|
||||
return new Reservation() {
|
||||
@Override public List<EscrowItem> items() { return List.of(new EscrowItem(material, amount, null)); }
|
||||
@Override public void returnTo(Player recipient) { assertEquals("main", lane); }
|
||||
};
|
||||
}
|
||||
};
|
||||
private final QuestClaimInventory inventory = items -> {
|
||||
assertEquals("main", lane);
|
||||
return player -> {
|
||||
assertEquals("main", lane);
|
||||
delivered.add(items);
|
||||
return new QuestClaimInventory.Delivery() {
|
||||
@Override public int overflowAmount() { return 0; }
|
||||
@Override public void rollback() { fail("No rollback after yielding"); }
|
||||
};
|
||||
};
|
||||
};
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test void nativeRegisteredBoardToDeathPersonalProgressReloadAndNativeClaim() throws Exception {
|
||||
Player issuer = player("Issuer");
|
||||
Player hunter = player("Hunter");
|
||||
Player rival = player("Rival");
|
||||
Player target = player("Target");
|
||||
RuntimeBoard runtime = startPlugin(target);
|
||||
QuestService service = runtime.service();
|
||||
QuestBoardDialogUi ui = ui(runtime.actions(), service, target);
|
||||
try (NativeDialogHarness nativeUi = new NativeDialogHarness()) {
|
||||
runtime.open(issuer);
|
||||
nativeUi.click("Create bounty", issuer, Map.of("bounty_target", "Target", "bounty_kills", "2"));
|
||||
flush();
|
||||
assertEquals(1, removed);
|
||||
Bounty bounty = service.activeBounties(AT).getFirst();
|
||||
assertEquals(reward, bounty.reward());
|
||||
assertEquals(target.getUniqueId(), bounty.targetId());
|
||||
now = AT.plusNanos(1); // Qualifying deaths occur strictly after posting.
|
||||
BountyDeathListener deaths = runtime.deaths();
|
||||
PlayerDeathEvent first = death(target, hunter);
|
||||
org.bukkit.damage.DamageSource projectile = mock(org.bukkit.damage.DamageSource.class);
|
||||
org.bukkit.entity.Projectile arrow = mock(org.bukkit.entity.Projectile.class);
|
||||
when(projectile.getDirectEntity()).thenReturn(arrow);
|
||||
when(first.getDamageSource()).thenReturn(projectile);
|
||||
deaths.onDeath(first);
|
||||
deaths.onDeath(first);
|
||||
flush();
|
||||
deaths.onDeath(death(target, rival));
|
||||
flush();
|
||||
runtime.open(hunter);
|
||||
assertTrue(nativeUi.messages.stream().anyMatch(text -> text.contains("Your progress: 1 / 2")
|
||||
&& text.contains(target.getUniqueId().toString()) && text.contains("Hunter's Prize")
|
||||
&& text.contains("Time remaining: 7d")), () -> nativeUi.messages.toString());
|
||||
assertFalse(ui.specification(hunter).questEntries().getFirst().actions().stream()
|
||||
.anyMatch(action -> action.route() == QuestBoardDialogSpec.Route.COMPLETE));
|
||||
deaths.onDeath(death(target, issuer));
|
||||
flush();
|
||||
assertEquals(0, service.state().bounties().get(bounty.id()).progress().getOrDefault(issuer.getUniqueId(), 0));
|
||||
deaths.onDeath(death(target, hunter));
|
||||
flush();
|
||||
assertTrue(service.activeBounties(AT).isEmpty());
|
||||
QuestService reloaded = load();
|
||||
assertEquals(hunter.getUniqueId(), reloaded.state().bounties().get(bounty.id()).winnerId());
|
||||
QuestBoardDialogUi afterRestart = ui(actions(reloaded), reloaded, target);
|
||||
openBoard(afterRestart, hunter);
|
||||
assertTrue(nativeUi.messages.stream().anyMatch(text -> text.contains("EARNED BOUNTY REWARD")
|
||||
&& text.contains("Hunter's Prize") && text.contains("— bounty " + bounty.id())));
|
||||
nativeUi.click("Collect pending claims", hunter, Map.of());
|
||||
flush();
|
||||
nativeUi.click("Collect pending claims", hunter, Map.of());
|
||||
flush();
|
||||
assertEquals(List.of(reward), delivered);
|
||||
assertTrue(load().claimsFor(hunter.getUniqueId()).isEmpty());
|
||||
runtime.plugin().onDisable();
|
||||
flush();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void disableBeforeReservationReturnsClaimToPendingWithoutUnsafeInventoryCallback() throws Exception {
|
||||
exerciseShutdown(false);
|
||||
}
|
||||
|
||||
@Test void disableDrainsAcceptedInventoryCallbackAndItsAcknowledgementExactlyOnce() throws Exception {
|
||||
exerciseShutdown(true);
|
||||
}
|
||||
|
||||
private void exerciseShutdown(boolean reserveBeforeDisable) throws Exception {
|
||||
Player issuer = player("Issuer");
|
||||
Player target = player("Target");
|
||||
Player hunter = player("Hunter");
|
||||
RuntimeBoard runtime = startPlugin(target);
|
||||
try (NativeDialogHarness nativeUi = new NativeDialogHarness()) {
|
||||
runtime.open(issuer);
|
||||
nativeUi.click("Create bounty", issuer, Map.of("bounty_target", "Target", "bounty_kills", "1"));
|
||||
flush();
|
||||
now = AT.plusNanos(1);
|
||||
runtime.deaths().onDeath(death(target, hunter));
|
||||
flush();
|
||||
runtime.open(hunter);
|
||||
nativeUi.click("Collect pending claims", hunter, Map.of());
|
||||
if (reserveBeforeDisable) { worker.drain(); }
|
||||
runtime.plugin().onDisable();
|
||||
flush();
|
||||
QuestService reloaded = load();
|
||||
assertEquals(reserveBeforeDisable ? List.of(reward) : List.of(), delivered);
|
||||
assertEquals(reserveBeforeDisable ? 0 : 1, reloaded.claimsFor(hunter.getUniqueId()).size());
|
||||
assertTrue(reloaded.state().transfers().isEmpty(), "Known undelivered reservations must be released, delivered ones acknowledged");
|
||||
nativeUi.click("Collect pending claims", hunter, Map.of());
|
||||
flush();
|
||||
assertEquals(reserveBeforeDisable ? 1 : 0, delivered.size(), "Native callbacks become inert after disable");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativeFailedSaveReturnsRewardAndCreatesNoBounty() throws Exception {
|
||||
QuestService service = new QuestService(new QuestRepository() {
|
||||
@Override public QuestState load() { return QuestState.empty(); }
|
||||
@Override public void save(QuestState state) throws java.io.IOException {
|
||||
assertEquals("worker", lane);
|
||||
throw new java.io.IOException("disk full");
|
||||
}
|
||||
});
|
||||
Player issuer = player("Issuer");
|
||||
QuestBoardDialogUi ui = ui(actions(service), service, player("Target"));
|
||||
try (NativeDialogHarness nativeUi = new NativeDialogHarness()) {
|
||||
openBoard(ui, issuer);
|
||||
nativeUi.click("Create bounty", issuer, Map.of("bounty_target", "Target", "bounty_kills", "1"));
|
||||
flush();
|
||||
assertEquals(0, removed, "Exact reward returned after failed save");
|
||||
assertTrue(service.state().bounties().isEmpty());
|
||||
assertTrue(delivered.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativeCancellationAndExpiryReturnExactEscrowAndCommandsRemainDisabled() throws Exception {
|
||||
QuestService service = load();
|
||||
Player issuer = player("Issuer");
|
||||
Player hunter = player("Hunter");
|
||||
Player target = player("Target");
|
||||
QuestActions actions = actions(service);
|
||||
QuestBoardDialogUi ui = ui(actions, service, target);
|
||||
PlayerCommandSettings settings = new PlayerCommandSettings(new PlayerCommandSettingsRepository() {
|
||||
@Override public boolean loadEnabled() { return false; }
|
||||
@Override public void saveEnabled(boolean enabled) { fail("Settings unchanged"); }
|
||||
});
|
||||
QuestCommand command = new QuestCommand(actions, service, clock, settings, null);
|
||||
command.onCommand(issuer, mock(org.bukkit.command.Command.class), "quests", new String[]{"claim"});
|
||||
assertTrue(worker.queue.isEmpty());
|
||||
try (NativeDialogHarness nativeUi = new NativeDialogHarness()) {
|
||||
openBoard(ui, issuer);
|
||||
nativeUi.click("Create bounty", issuer, Map.of("bounty_target", "Target", "bounty_kills", "2"));
|
||||
flush();
|
||||
Bounty first = service.activeBounties(AT).getFirst();
|
||||
now = AT.plusNanos(1);
|
||||
new BountyDeathListener(actions, clock).onDeath(death(target, hunter));
|
||||
flush();
|
||||
assertTrue(ui.specification(hunter).questEntries().getFirst().actions().isEmpty());
|
||||
openBoard(ui, issuer);
|
||||
nativeUi.click("Cancel bounty", issuer, Map.of());
|
||||
flush();
|
||||
assertEquals(QuestStatus.CANCELLED, service.state().bounties().get(first.id()).status());
|
||||
assertTrue(service.claimsFor(hunter.getUniqueId()).isEmpty());
|
||||
openBoard(ui, issuer);
|
||||
nativeUi.click("Collect pending claims", issuer, Map.of());
|
||||
flush();
|
||||
assertEquals(List.of(reward), delivered);
|
||||
openBoard(ui, issuer);
|
||||
nativeUi.click("Create bounty", issuer, Map.of("bounty_target", "Target", "bounty_kills", "2"));
|
||||
flush();
|
||||
actions.expire(now.plusSeconds(604800));
|
||||
flush();
|
||||
QuestService reloaded = load();
|
||||
assertEquals(QuestClaimSource.EXPIRATION, reloaded.claimsFor(issuer.getUniqueId()).getFirst().source());
|
||||
openBoard(ui(actions(reloaded), reloaded, target), issuer);
|
||||
nativeUi.click("Collect pending claims", issuer, Map.of());
|
||||
flush();
|
||||
assertEquals(List.of(reward, reward), delivered);
|
||||
assertTrue(load().claimsFor(issuer.getUniqueId()).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativeLegacyCreateCompleteAndClaimStillUseRealAsyncService() throws Exception {
|
||||
QuestService service = load();
|
||||
Player issuer = player("Issuer");
|
||||
Player hunter = player("Hunter");
|
||||
QuestActions actions = actions(service);
|
||||
QuestBoardDialogUi ui = ui(actions, service, player("Target"));
|
||||
try (NativeDialogHarness nativeUi = new NativeDialogHarness()) {
|
||||
openBoard(ui, issuer);
|
||||
nativeUi.click("Create quest", issuer, Map.of("requested_material", "stone", "requested_quantity", "4"));
|
||||
flush();
|
||||
Quest quest = service.activeQuests(AT).getFirst();
|
||||
openBoard(ui, hunter);
|
||||
nativeUi.click("Complete quest", hunter, Map.of());
|
||||
flush();
|
||||
assertEquals(QuestStatus.COMPLETED, service.state().quests().get(quest.id()).status());
|
||||
assertEquals(List.of(reward), delivered);
|
||||
openBoard(ui, issuer);
|
||||
nativeUi.click("Collect pending claims", issuer, Map.of());
|
||||
flush();
|
||||
assertEquals(List.of(new EscrowItem("STONE", 4, null)), delivered.getLast());
|
||||
assertTrue(load().claimsFor(issuer.getUniqueId()).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void bountyRulesAndInvalidCountsAreVisibleAndNeverRemoveReward() throws Exception {
|
||||
QuestService service = load();
|
||||
Player issuer = player("Issuer");
|
||||
QuestBoardDialogUi ui = ui(actions(service), service, player("Target"));
|
||||
try (NativeDialogHarness nativeUi = new NativeDialogHarness()) {
|
||||
openBoard(ui, issuer);
|
||||
assertTrue(nativeUi.messages.stream().anyMatch(text -> text.contains("first hunter")
|
||||
&& text.contains("issuer") && text.contains("partial") && text.contains("cancel")
|
||||
&& text.contains("7 days")));
|
||||
for (String kills : List.of("0", "-1", "1.5", "2147483648", "")) {
|
||||
nativeUi.click("Create bounty", issuer, Map.of("bounty_target", "Target", "bounty_kills", kills));
|
||||
flush();
|
||||
}
|
||||
nativeUi.click("Create bounty", issuer, Map.of("bounty_target", "bad/target", "bounty_kills", "2"));
|
||||
flush();
|
||||
assertEquals(0, removed);
|
||||
assertTrue(service.activeBounties(AT).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativeClaimsExplainRecoveryItemsAndQuarantinedTransfers() throws Exception {
|
||||
QuestService service = load();
|
||||
Player issuer = player("Issuer");
|
||||
Player hunter = player("Hunter");
|
||||
Player target = player("Target");
|
||||
UUID owner = hunter.getUniqueId();
|
||||
UUID issuerId = issuer.getUniqueId();
|
||||
UUID targetId = target.getUniqueId();
|
||||
lane = "worker";
|
||||
try {
|
||||
service.createBounty(issuerId, "Issuer", targetId, "Target", 1, reward, AT);
|
||||
service.recordDeath(UUID.randomUUID(), targetId, owner, AT.plusSeconds(1));
|
||||
service.reserveClaims(owner);
|
||||
service.retainReturn(new ClaimTransfer(UUID.randomUUID(), owner, null, reward,
|
||||
ClaimTransfer.Status.PENDING));
|
||||
} finally { lane = "main"; }
|
||||
try (NativeDialogHarness nativeUi = new NativeDialogHarness()) {
|
||||
openBoard(ui(actions(service), service, target), hunter);
|
||||
assertTrue(nativeUi.messages.stream().anyMatch(text -> text.contains("RECOVERED INPUT")
|
||||
&& text.contains("Hunter's Prize") && text.contains("RESERVED")
|
||||
&& text.contains("reconciliation")), () -> nativeUi.messages.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void earnedClaimHasItsOwnTypeAndExactCustomName() throws Exception {
|
||||
QuestService service = load();
|
||||
Player hunter = player("Hunter");
|
||||
QuestClaim claim = new QuestClaim(UUID.randomUUID(), UUID.randomUUID(), hunter.getUniqueId(), reward, AT, QuestClaimSource.BOUNTY);
|
||||
assertEquals("EARNED_BOUNTY_REWARD", claim.type().name());
|
||||
assertNotEquals(QuestClaimType.RETURNED_REWARD, claim.type());
|
||||
}
|
||||
|
||||
@Test void nativeCallbacksRejectOtherAudienceAndMarshalOriginalPlayerToMain() throws Exception {
|
||||
QuestService service = load();
|
||||
Player issuer = player("Issuer");
|
||||
Player attacker = player("Attacker");
|
||||
QuestActions actions = mock(QuestActions.class);
|
||||
QuestBoardDialogUi ui = ui(actions, service, player("Target"));
|
||||
try (NativeDialogHarness nativeUi = new NativeDialogHarness()) {
|
||||
openBoard(ui, issuer);
|
||||
var create = nativeUi.callback("Create quest");
|
||||
var response = mock(io.papermc.paper.dialog.DialogResponseView.class);
|
||||
when(response.getText("requested_material")).thenReturn("stone");
|
||||
when(response.getText("requested_quantity")).thenReturn("2");
|
||||
lane = "network";
|
||||
create.accept(response, attacker);
|
||||
lane = "main";
|
||||
flush();
|
||||
verifyNoInteractions(actions);
|
||||
lane = "network";
|
||||
create.accept(response, issuer);
|
||||
verifyNoInteractions(actions);
|
||||
lane = "main";
|
||||
flush();
|
||||
verify(actions).create(issuer, "stone", 2, AT);
|
||||
verifyNoMoreInteractions(actions);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void harnessExecutesExistingNativeCreationCallback() {
|
||||
QuestCreationGateway creator = mock(QuestCreationGateway.class);
|
||||
QuestService service = mock(QuestService.class);
|
||||
when(service.activeQuests(any())).thenReturn(List.of());
|
||||
Player issuer = player("Issuer");
|
||||
Quest quest = new Quest(UUID.randomUUID(), issuer.getUniqueId(), "Issuer", "STONE", 1,
|
||||
reward, AT, AT.plusSeconds(604800));
|
||||
try {
|
||||
when(creator.create(eq(issuer), eq("stone"), eq(1), eq(AT))).thenReturn(quest);
|
||||
try (NativeDialogHarness nativeUi = new NativeDialogHarness()) {
|
||||
new QuestBoardDialogUi(creator, service, clock).open(issuer);
|
||||
nativeUi.click("Create quest", issuer, Map.of("requested_material", "stone", "requested_quantity", "1"));
|
||||
verify(creator).create(issuer, "stone", 1, AT);
|
||||
}
|
||||
} catch (java.io.IOException exception) { throw new AssertionError(exception); }
|
||||
}
|
||||
|
||||
private QuestService load() throws Exception {
|
||||
lane = "worker";
|
||||
try {
|
||||
YamlQuestRepository yaml = new YamlQuestRepository(directory.resolve("quests.yml"));
|
||||
return new QuestService(new QuestRepository() {
|
||||
@Override public QuestState load() throws java.io.IOException { assertEquals("worker", lane); return yaml.load(); }
|
||||
@Override public void save(QuestState state) throws java.io.IOException { assertEquals("worker", lane); yaml.save(state); }
|
||||
});
|
||||
} finally { lane = "main"; }
|
||||
}
|
||||
private QuestActions actions(QuestService service) {
|
||||
return actions(service, worker, main);
|
||||
}
|
||||
private QuestActions actions(QuestService service, Executor persistence, Executor callbacks) {
|
||||
ItemMaterialCatalog catalog = mock(ItemMaterialCatalog.class);
|
||||
when(catalog.normalizeItem(anyString())).thenAnswer(call -> Optional.of(((String) call.getArgument(0)).toUpperCase(Locale.ROOT)));
|
||||
return new AsyncQuestActions(service, persistence, callbacks, inputs, inventory, catalog, id -> null, mock(Logger.class));
|
||||
}
|
||||
private QuestBoardDialogUi ui(QuestActions actions, QuestService service, Player target) {
|
||||
Server server = mock(Server.class);
|
||||
when(server.getPlayerExact("Target")).thenReturn(target);
|
||||
return new QuestBoardDialogUi(actions, service, new BountyTargetResolver(server, main), main, clock);
|
||||
}
|
||||
private Player player(String name) {
|
||||
Player player = mock(Player.class);
|
||||
UUID id = UUID.randomUUID();
|
||||
when(player.getUniqueId()).thenAnswer(call -> { assertEquals("main", lane); return id; });
|
||||
when(player.getName()).thenAnswer(call -> { assertEquals("main", lane); return name; });
|
||||
when(player.isOnline()).thenAnswer(call -> { assertEquals("main", lane); return true; });
|
||||
when(player.isDead()).thenAnswer(call -> { assertEquals("main", lane); return false; });
|
||||
doAnswer(call -> { assertEquals("main", lane); return null; }).when(player).sendMessage(anyString());
|
||||
return player;
|
||||
}
|
||||
private static PlayerDeathEvent death(Player target, Player killer) {
|
||||
when(target.getKiller()).thenReturn(killer);
|
||||
PlayerDeathEvent event = mock(PlayerDeathEvent.class);
|
||||
when(event.getEntity()).thenReturn(target);
|
||||
return event;
|
||||
}
|
||||
private static void openBoard(QuestBoardUi ui, Player player) throws Exception {
|
||||
UUID worldId = UUID.randomUUID();
|
||||
BoardRegistry boards = new BoardRegistry(new BoardRepository() {
|
||||
@Override public BoardState load() { return new BoardState(Set.of(new RegisteredBoard(new BoardId(worldId, 1, 64, 1), "world"))); }
|
||||
@Override public void save(BoardState state) { fail("Board browsing does not save"); }
|
||||
});
|
||||
openRegistered(new QuestBoardInteractionListener(boards, ui), worldId, player);
|
||||
}
|
||||
private static void openRegistered(QuestBoardInteractionListener listener, UUID worldId, Player player) {
|
||||
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
|
||||
Block block = mock(Block.class);
|
||||
World world = mock(World.class);
|
||||
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
|
||||
when(event.getHand()).thenReturn(EquipmentSlot.HAND);
|
||||
when(event.getClickedBlock()).thenReturn(block);
|
||||
when(event.getPlayer()).thenReturn(player);
|
||||
when(block.getWorld()).thenReturn(world);
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
when(world.getName()).thenReturn("world");
|
||||
when(block.getX()).thenReturn(1);
|
||||
when(block.getY()).thenReturn(64);
|
||||
when(block.getZ()).thenReturn(1);
|
||||
listener.onPlayerInteract(event);
|
||||
verify(event).setCancelled(true);
|
||||
}
|
||||
private RuntimeBoard startPlugin(Player target) throws Exception {
|
||||
UUID worldId = UUID.randomUUID();
|
||||
new YamlBoardRepository(directory.resolve("boards.yml")).save(new BoardState(Set.of(
|
||||
new RegisteredBoard(new BoardId(worldId, 1, 64, 1), "world"))));
|
||||
SpigotQuestBoardPlugin plugin = mock(SpigotQuestBoardPlugin.class, CALLS_REAL_METHODS);
|
||||
Server server = mock(Server.class);
|
||||
org.bukkit.plugin.PluginManager manager = mock(org.bukkit.plugin.PluginManager.class);
|
||||
when(server.getPluginManager()).thenReturn(manager);
|
||||
when(server.getScheduler()).thenReturn(mock(org.bukkit.scheduler.BukkitScheduler.class));
|
||||
when(server.getPlayerExact("Target")).thenReturn(target);
|
||||
doReturn(server).when(plugin).getServer();
|
||||
doReturn(directory.toFile()).when(plugin).getDataFolder();
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
doReturn(clock).when(plugin).runtimeClock();
|
||||
doReturn(mock(org.bukkit.command.PluginCommand.class)).when(plugin).getCommand(anyString());
|
||||
QuestRuntimeScheduler scheduler = new QuestRuntimeScheduler(worker, main::execute, () -> "main".equals(lane));
|
||||
doReturn(scheduler).when(plugin).createRuntime();
|
||||
QuestService[] services = new QuestService[1];
|
||||
QuestActions[] actions = new QuestActions[1];
|
||||
doAnswer(call -> {
|
||||
assertEquals("worker", lane, "recovery adapter must exist even if disable prevents main startup");
|
||||
services[0] = call.getArgument(0);
|
||||
actions[0] = actions(services[0], scheduler.worker(), scheduler.main());
|
||||
return actions[0];
|
||||
}).when(plugin).runtimeActions(any(), eq(scheduler));
|
||||
plugin.onEnable();
|
||||
verify(manager).registerEvents(isA(BountyDeathListener.class), eq(plugin));
|
||||
flush();
|
||||
var listeners = org.mockito.ArgumentCaptor.forClass(org.bukkit.event.Listener.class);
|
||||
verify(manager, atLeastOnce()).registerEvents(listeners.capture(), eq(plugin));
|
||||
QuestBoardInteractionListener board = listeners.getAllValues().stream()
|
||||
.filter(QuestBoardInteractionListener.class::isInstance).map(QuestBoardInteractionListener.class::cast).findFirst().orElseThrow();
|
||||
BountyDeathListener deaths = listeners.getAllValues().stream()
|
||||
.filter(BountyDeathListener.class::isInstance).map(BountyDeathListener.class::cast).findFirst().orElseThrow();
|
||||
return new RuntimeBoard(plugin, board, deaths, services[0], actions[0], worldId);
|
||||
}
|
||||
private record RuntimeBoard(SpigotQuestBoardPlugin plugin, QuestBoardInteractionListener board,
|
||||
BountyDeathListener deaths, QuestService service, QuestActions actions, UUID worldId) {
|
||||
void open(Player player) { openRegistered(board, worldId, player); }
|
||||
}
|
||||
|
||||
private void flush() {
|
||||
while (!worker.queue.isEmpty() || !main.queue.isEmpty()) { worker.drain(); main.drain(); }
|
||||
}
|
||||
private final class Lane extends java.util.concurrent.AbstractExecutorService {
|
||||
private final String name;
|
||||
private final Queue<Runnable> queue = new ArrayDeque<>();
|
||||
Lane(String name) { this.name = name; }
|
||||
private boolean closed;
|
||||
@Override public void execute(Runnable runnable) {
|
||||
if (closed) { throw new java.util.concurrent.RejectedExecutionException(); }
|
||||
queue.add(runnable);
|
||||
}
|
||||
@Override public void shutdown() { closed = true; }
|
||||
@Override public List<Runnable> shutdownNow() { throw new AssertionError("No queue discard"); }
|
||||
@Override public boolean isShutdown() { return closed; }
|
||||
@Override public boolean isTerminated() { return closed && queue.isEmpty(); }
|
||||
@Override public boolean awaitTermination(long timeout, java.util.concurrent.TimeUnit unit) { throw new AssertionError("No tick wait"); }
|
||||
void drain() {
|
||||
lane = name;
|
||||
try { while (!queue.isEmpty()) { queue.remove().run(); } }
|
||||
finally { lane = "main"; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.time.*;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.entity.Projectile;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
class BountyDeathListenerTest {
|
||||
private static final Instant AT = Instant.parse("2026-01-01T00:00:00Z");
|
||||
private final QuestActions actions = mock(QuestActions.class);
|
||||
private final BountyDeathListener listener = new BountyDeathListener(actions, Clock.fixed(AT, ZoneOffset.UTC));
|
||||
|
||||
@Test void routesServerAttributedProjectileAndRetainsIdForDuplicateEventButNotNextDeath() {
|
||||
Player target = player();
|
||||
Player killer = player();
|
||||
when(target.getKiller()).thenReturn(killer);
|
||||
PlayerDeathEvent first = mock(PlayerDeathEvent.class);
|
||||
when(first.getEntity()).thenReturn(target);
|
||||
DamageSource source = mock(DamageSource.class);
|
||||
when(first.getDamageSource()).thenReturn(source);
|
||||
when(source.getDirectEntity()).thenReturn(mock(Projectile.class));
|
||||
PlayerDeathEvent next = mock(PlayerDeathEvent.class);
|
||||
when(next.getEntity()).thenReturn(target);
|
||||
listener.onDeath(first);
|
||||
listener.onDeath(first);
|
||||
listener.onDeath(next);
|
||||
ArgumentCaptor<UUID> ids = ArgumentCaptor.forClass(UUID.class);
|
||||
UUID targetId = target.getUniqueId();
|
||||
UUID killerId = killer.getUniqueId();
|
||||
verify(actions, times(3)).death(ids.capture(), eq(targetId), eq(killerId), eq(AT));
|
||||
assertEquals(ids.getAllValues().get(0), ids.getAllValues().get(1));
|
||||
assertNotEquals(ids.getAllValues().get(0), ids.getAllValues().get(2));
|
||||
verify(first, never()).setCancelled(anyBoolean());
|
||||
verify(first, never()).setDroppedExp(anyInt());
|
||||
verify(first, never()).getDamageSource(); // attribution belongs to Bukkit, not our projectile heuristic
|
||||
}
|
||||
|
||||
@Test void duplicateEventRetainsOriginalAttributionEvenIfPlayersLiveAnotherLife() {
|
||||
Player target = player();
|
||||
Player original = player();
|
||||
Player later = player();
|
||||
UUID targetId = target.getUniqueId();
|
||||
UUID originalId = original.getUniqueId();
|
||||
when(target.getKiller()).thenReturn(original);
|
||||
PlayerDeathEvent event = mock(PlayerDeathEvent.class);
|
||||
when(event.getEntity()).thenReturn(target);
|
||||
listener.onDeath(event);
|
||||
when(target.getKiller()).thenReturn(later);
|
||||
listener.onDeath(event);
|
||||
verify(actions, times(2)).death(any(), eq(targetId), eq(originalId), eq(AT));
|
||||
verifyNoMoreInteractions(actions);
|
||||
}
|
||||
|
||||
@Test void originallyUncreditedEventCannotBecomeCreditedWhenReplayedAfterAnotherLife() {
|
||||
Player target = player();
|
||||
PlayerDeathEvent event = mock(PlayerDeathEvent.class);
|
||||
when(event.getEntity()).thenReturn(target);
|
||||
listener.onDeath(event);
|
||||
Player later = player();
|
||||
when(target.getKiller()).thenReturn(later);
|
||||
listener.onDeath(event);
|
||||
verifyNoInteractions(actions);
|
||||
}
|
||||
|
||||
@Test void canceledUncreditedAndSelfDeathsDoNotReachActions() {
|
||||
Player target = player();
|
||||
PlayerDeathEvent event = mock(PlayerDeathEvent.class);
|
||||
when(event.getEntity()).thenReturn(target);
|
||||
when(event.isCancelled()).thenReturn(true);
|
||||
Player killer = player();
|
||||
when(target.getKiller()).thenReturn(killer);
|
||||
listener.onDeath(event);
|
||||
when(event.isCancelled()).thenReturn(false);
|
||||
when(target.getKiller()).thenReturn(null);
|
||||
listener.onDeath(event);
|
||||
when(target.getKiller()).thenReturn(target);
|
||||
listener.onDeath(event);
|
||||
verifyNoInteractions(actions);
|
||||
}
|
||||
|
||||
@Test void observesAtMonitorAndIgnoresCancelledNativeEvents() throws Exception {
|
||||
EventHandler annotation = BountyDeathListener.class.getMethod("onDeath", PlayerDeathEvent.class)
|
||||
.getAnnotation(EventHandler.class);
|
||||
assertNotNull(annotation);
|
||||
assertEquals(EventPriority.MONITOR, annotation.priority());
|
||||
assertTrue(annotation.ignoreCancelled());
|
||||
}
|
||||
|
||||
private static Player player() {
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
return player;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
final class BountyRepositoryTest {
|
||||
@TempDir Path directory;
|
||||
private static final Instant POSTED = Instant.EPOCH;
|
||||
private static final UUID ISSUER = UUID.randomUUID();
|
||||
private static final UUID TARGET = UUID.randomUUID();
|
||||
private static final UUID HUNTER = UUID.randomUUID();
|
||||
private static final List<EscrowItem> REWARD = List.of(
|
||||
new EscrowItem("DIAMOND_SWORD", 1, "opaque-item-data", "Exact bounty prize"),
|
||||
new EscrowItem("DIAMOND", 2, null)
|
||||
);
|
||||
|
||||
@Test
|
||||
void realYamlRestartRetainsMixedStateProgressDeduplicationWinnerAndClaims() throws Exception {
|
||||
YamlQuestRepository repository = new YamlQuestRepository(directory.resolve("quests.yml"));
|
||||
QuestService service = new QuestService(repository);
|
||||
Quest quest = service.create(ISSUER, "Issuer", "STONE", 2, REWARD, POSTED);
|
||||
service.complete(quest.id(), new EscrowItem("STONE", 2, "delivered-data"), POSTED.plusSeconds(1));
|
||||
Bounty bounty = post(service, 2);
|
||||
UUID death = UUID.randomUUID();
|
||||
service.recordDeath(death, TARGET, HUNTER, POSTED.plusSeconds(2));
|
||||
QuestState beforeRestart = service.state();
|
||||
QuestService reloaded = new QuestService(repository);
|
||||
assertEquals(beforeRestart, reloaded.state());
|
||||
assertTrue(reloaded.recordDeath(death, TARGET, HUNTER, POSTED.plusSeconds(3)).isEmpty());
|
||||
List<QuestClaim> earned = reloaded.recordDeath(UUID.randomUUID(), TARGET, HUNTER, POSTED.plusSeconds(4));
|
||||
assertEquals(1, earned.size());
|
||||
assertEquals(REWARD, earned.getFirst().items());
|
||||
assertEquals(QuestClaimSource.BOUNTY, earned.getFirst().source());
|
||||
assertEquals(HUNTER, reloaded.state().bounties().get(bounty.id()).winnerId());
|
||||
assertEquals(reloaded.state(), repository.load());
|
||||
reloaded.acknowledgeClaim(HUNTER, earned.getFirst().id());
|
||||
QuestService claimed = new QuestService(repository);
|
||||
assertTrue(claimed.claimsFor(HUNTER).isEmpty());
|
||||
assertEquals(1, claimed.claimsFor(ISSUER).size());
|
||||
assertTrue(claimed.recordDeath(death, TARGET, HUNTER, POSTED.plusSeconds(5)).isEmpty());
|
||||
assertTrue(claimed.expire(bounty.expiresAt()).isEmpty());
|
||||
assertEquals(QuestStatus.COMPLETED, claimed.state().bounties().get(bounty.id()).status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void absenceOfBountiesInLegacyYamlPreservesQuestAndSourcelessClaims() throws Exception {
|
||||
Path path = directory.resolve("legacy.yml");
|
||||
YamlQuestRepository repository = new YamlQuestRepository(path);
|
||||
QuestService service = new QuestService(repository);
|
||||
Quest quest = service.create(ISSUER, "Issuer", "STONE", 2, REWARD, POSTED);
|
||||
service.cancel(quest.id(), ISSUER, POSTED.plusSeconds(1));
|
||||
QuestState legacy = service.state();
|
||||
YamlConfiguration yaml = YamlConfiguration.loadConfiguration(path.toFile());
|
||||
yaml.set("bounties", null);
|
||||
List<Map<?, ?>> claims = yaml.getMapList("claims");
|
||||
claims.getFirst().remove("source");
|
||||
yaml.set("claims", claims);
|
||||
yaml.save(path.toFile());
|
||||
assertEquals(legacy, repository.load());
|
||||
QuestService reloaded = new QuestService(repository);
|
||||
post(reloaded, 1);
|
||||
assertEquals(legacy.quests(), repository.load().quests());
|
||||
assertEquals(legacy.claims(), repository.load().claims());
|
||||
assertEquals(legacy.notifications(), repository.load().notifications());
|
||||
assertEquals(1, repository.load().bounties().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelledAndExpiredPartialProgressSurvivesReloadWithExactReturns() throws Exception {
|
||||
YamlQuestRepository repository = new YamlQuestRepository(directory.resolve("returns.yml"));
|
||||
QuestService service = new QuestService(repository);
|
||||
Bounty cancelled = post(service, 3);
|
||||
Bounty expired = post(service, 2);
|
||||
service.recordDeath(UUID.randomUUID(), TARGET, HUNTER, POSTED.plusSeconds(1));
|
||||
service.cancelBounty(cancelled.id(), ISSUER, POSTED.plusSeconds(2));
|
||||
service.expire(expired.expiresAt());
|
||||
assertEquals(service.state(), repository.load());
|
||||
QuestService reloaded = new QuestService(repository);
|
||||
assertEquals(QuestStatus.CANCELLED, reloaded.state().bounties().get(cancelled.id()).status());
|
||||
assertEquals(QuestStatus.EXPIRED, reloaded.state().bounties().get(expired.id()).status());
|
||||
assertEquals(2, reloaded.claimsFor(ISSUER).size());
|
||||
assertTrue(reloaded.claimsFor(HUNTER).isEmpty());
|
||||
for (QuestClaim claim : reloaded.claimsFor(ISSUER)) {
|
||||
assertEquals(REWARD, claim.items());
|
||||
reloaded.acknowledgeClaim(ISSUER, claim.id());
|
||||
}
|
||||
assertTrue(new QuestService(repository).claimsFor(ISSUER).isEmpty());
|
||||
}
|
||||
|
||||
@org.junit.jupiter.api.TestFactory
|
||||
java.util.stream.Stream<org.junit.jupiter.api.DynamicTest> malformedBountyRecordsFailClosedWithoutChangingFile() {
|
||||
List<BadRecord> cases = List.of(
|
||||
new BadRecord("missing target", row -> row.remove("target-id")),
|
||||
new BadRecord("invalid target", row -> row.put("target-id", "not-a-uuid")),
|
||||
new BadRecord("blank target name", row -> row.put("target-name", " ")),
|
||||
new BadRecord("missing issuer", row -> row.remove("issuer-id")),
|
||||
new BadRecord("missing id", row -> row.remove("id")),
|
||||
new BadRecord("zero kills", row -> row.put("required-kills", 0)),
|
||||
new BadRecord("negative kills", row -> row.put("required-kills", -1)),
|
||||
new BadRecord("fractional kills", row -> row.put("required-kills", 1.5)),
|
||||
new BadRecord("overflow kills", row -> row.put("required-kills", 4294967297L)),
|
||||
new BadRecord("string kills", row -> row.put("required-kills", "2")),
|
||||
new BadRecord("missing reward", row -> row.remove("reward")),
|
||||
new BadRecord("empty reward", row -> row.put("reward", List.of())),
|
||||
new BadRecord("bad reward entry", row -> row.put("reward", List.of("DIAMOND"))),
|
||||
new BadRecord("fractional item amount", row -> row.put("reward", List.of(Map.of("material", "DIAMOND", "amount", 1.5)))),
|
||||
new BadRecord("invalid timestamp", row -> row.put("created-at", "yesterday")),
|
||||
new BadRecord("expiry before posting", row -> row.put("expires-at", POSTED.minusSeconds(1).toString())),
|
||||
new BadRecord("missing status", row -> row.remove("status")),
|
||||
new BadRecord("invalid status", row -> row.put("status", "WINNER")),
|
||||
new BadRecord("missing progress", row -> row.remove("progress")),
|
||||
new BadRecord("nonmap progress", row -> row.put("progress", List.of())),
|
||||
new BadRecord("bad hunter", row -> row.put("progress", Map.of("invalid", 1))),
|
||||
new BadRecord("negative progress", row -> row.put("progress", Map.of(HUNTER.toString(), -1))),
|
||||
new BadRecord("zero progress", row -> row.put("progress", Map.of(HUNTER.toString(), 0))),
|
||||
new BadRecord("fractional progress", row -> progress(row, HUNTER, 1.5)),
|
||||
new BadRecord("overflow progress", row -> progress(row, HUNTER, 4294967297L)),
|
||||
new BadRecord("issuer progress", row -> progress(row, ISSUER, 1)),
|
||||
new BadRecord("self progress", row -> progress(row, TARGET, 1)),
|
||||
new BadRecord("progress without death ids", row -> row.put("progress", Map.of(HUNTER.toString(), 1))),
|
||||
new BadRecord("death ids without progress", row -> row.put("processed-deaths", List.of(UUID.randomUUID().toString()))),
|
||||
new BadRecord("missing processed deaths", row -> row.remove("processed-deaths")),
|
||||
new BadRecord("bad death id", row -> row.put("processed-deaths", List.of("bad"))),
|
||||
new BadRecord("duplicate death id", row -> { String id = UUID.randomUUID().toString(); row.put("processed-deaths", List.of(id, id)); }),
|
||||
new BadRecord("active winner", row -> row.put("winner-id", HUNTER.toString())),
|
||||
new BadRecord("completed without winner", row -> row.put("status", "COMPLETED")),
|
||||
new BadRecord("completed without sufficient progress", row -> { row.put("status", "COMPLETED"); row.put("winner-id", HUNTER.toString()); }),
|
||||
new BadRecord("completed winner is issuer", row -> { row.put("status", "COMPLETED"); row.put("winner-id", ISSUER.toString()); }),
|
||||
new BadRecord("unfinished already reached requirement", row -> { row.put("required-kills", 1); progress(row, HUNTER, 1); })
|
||||
);
|
||||
return cases.stream().map(test -> org.junit.jupiter.api.DynamicTest.dynamicTest(test.name(), () -> {
|
||||
Path path = directory.resolve(test.name().replace(' ', '-') + ".yml");
|
||||
YamlQuestRepository repository = new YamlQuestRepository(path);
|
||||
QuestService service = new QuestService(repository);
|
||||
service.create(ISSUER, "Issuer", "STONE", 1, REWARD, POSTED);
|
||||
post(service, 2);
|
||||
YamlConfiguration yaml = YamlConfiguration.loadConfiguration(path.toFile());
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
yaml.getMapList("bounties").getFirst().forEach((key, value) -> row.put((String) key, value));
|
||||
test.change().accept(row);
|
||||
yaml.set("bounties", List.of(row));
|
||||
yaml.save(path.toFile());
|
||||
String corrupt = Files.readString(path);
|
||||
assertThrows(IOException.class, repository::load);
|
||||
assertEquals(corrupt, Files.readString(path));
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedBountyCollectionAndDuplicateIdsNeverSilentlyDropEscrow() throws Exception {
|
||||
Path path = directory.resolve("bad-collection.yml");
|
||||
YamlQuestRepository repository = new YamlQuestRepository(path);
|
||||
QuestService service = new QuestService(repository);
|
||||
post(service, 2);
|
||||
YamlConfiguration yaml = YamlConfiguration.loadConfiguration(path.toFile());
|
||||
Map<?, ?> entry = yaml.getMapList("bounties").getFirst();
|
||||
for (Object malformed : List.of("not-a-list", Map.of("bad", "map"),
|
||||
List.of("not-a-record"), List.of(entry, "not-a-record"), List.of(entry, entry))) {
|
||||
yaml.set("bounties", malformed);
|
||||
yaml.save(path.toFile());
|
||||
assertThrows(IOException.class, repository::load, malformed.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedAtomicReplacementDoesNotPublishStateAndCanRetry() throws Exception {
|
||||
Path path = directory.resolve("quests.yml");
|
||||
YamlQuestRepository repository = new YamlQuestRepository(path);
|
||||
QuestService service = new QuestService(repository);
|
||||
Bounty bounty = post(service, 1);
|
||||
byte[] original = Files.readAllBytes(path);
|
||||
Files.delete(path);
|
||||
Files.createDirectory(path);
|
||||
Files.writeString(path.resolve("blocker"), "force replacement failure");
|
||||
UUID death = UUID.randomUUID();
|
||||
assertThrows(IOException.class, () -> service.recordDeath(death, TARGET, HUNTER, POSTED.plusSeconds(1)));
|
||||
assertEquals(bounty, service.state().bounties().get(bounty.id()));
|
||||
assertTrue(service.claimsFor(HUNTER).isEmpty());
|
||||
try (var files = Files.list(directory)) {
|
||||
assertFalse(files.anyMatch(file -> file.toString().endsWith(".tmp")));
|
||||
}
|
||||
Files.delete(path.resolve("blocker"));
|
||||
Files.delete(path);
|
||||
Files.write(path, original);
|
||||
assertEquals(service.state(), repository.load());
|
||||
assertEquals(1, service.recordDeath(death, TARGET, HUNTER, POSTED.plusSeconds(1)).size());
|
||||
assertEquals(service.state(), repository.load());
|
||||
}
|
||||
|
||||
private static void progress(Map<String, Object> row, UUID hunter, Number kills) {
|
||||
row.put("progress", Map.of(hunter.toString(), kills));
|
||||
row.put("processed-deaths", List.of(UUID.randomUUID().toString()));
|
||||
}
|
||||
|
||||
private record BadRecord(String name, java.util.function.Consumer<Map<String, Object>> change) { }
|
||||
|
||||
private static Bounty post(QuestService service, int requiredKills) throws IOException {
|
||||
return service.createBounty(ISSUER, "Issuer", TARGET, "Target", requiredKills, REWARD, POSTED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class BountyServiceTest {
|
||||
private static final Instant POSTED = Instant.EPOCH;
|
||||
private static final UUID ISSUER = UUID.randomUUID();
|
||||
private static final UUID TARGET = UUID.randomUUID();
|
||||
private static final UUID HUNTER = UUID.randomUUID();
|
||||
private static final List<EscrowItem> REWARD = List.of(
|
||||
new EscrowItem("DIAMOND_SWORD", 1, "exact-data", "Hunter's prize"),
|
||||
new EscrowItem("DIAMOND", 3, null)
|
||||
);
|
||||
|
||||
@Test
|
||||
void createsDurableSevenDayBountyWithIndependentImmutableState() throws Exception {
|
||||
MemoryRepository repository = new MemoryRepository();
|
||||
QuestService service = new QuestService(repository);
|
||||
List<EscrowItem> reward = new ArrayList<>(REWARD);
|
||||
Bounty bounty = service.createBounty(ISSUER, "Issuer", TARGET, "Target", 2, reward, POSTED);
|
||||
reward.clear();
|
||||
|
||||
assertEquals(ISSUER, bounty.issuerId());
|
||||
assertEquals(TARGET, bounty.targetId());
|
||||
assertEquals("Target", bounty.targetName());
|
||||
assertEquals(2, bounty.requiredKills());
|
||||
assertEquals(REWARD, bounty.reward());
|
||||
assertEquals(POSTED, bounty.createdAt());
|
||||
assertEquals(POSTED.plus(7, ChronoUnit.DAYS), bounty.expiresAt());
|
||||
assertEquals(QuestStatus.ACTIVE, bounty.status());
|
||||
assertEquals(Map.of(), bounty.progress());
|
||||
assertEquals(Set.of(), bounty.processedDeaths());
|
||||
assertNull(bounty.winnerId());
|
||||
assertEquals(bounty, repository.state.bounties().get(bounty.id()));
|
||||
assertEquals(List.of(bounty), service.activeBounties(POSTED));
|
||||
assertTrue(service.activeBounties(bounty.expiresAt()).isEmpty());
|
||||
assertThrows(UnsupportedOperationException.class, () -> bounty.reward().clear());
|
||||
assertThrows(UnsupportedOperationException.class, () -> bounty.progress().put(HUNTER, 1));
|
||||
assertThrows(UnsupportedOperationException.class, () -> bounty.processedDeaths().add(UUID.randomUUID()));
|
||||
assertThrows(UnsupportedOperationException.class, () -> service.state().bounties().clear());
|
||||
assertTrue(new QuestState(Map.of()).bounties().isEmpty());
|
||||
assertTrue(new QuestState(Map.of(), Map.of(), Map.of()).bounties().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidCreationAndNeverPublishesFailedSave() throws Exception {
|
||||
MemoryRepository repository = new MemoryRepository();
|
||||
QuestService service = new QuestService(repository);
|
||||
assertThrows(IllegalArgumentException.class, () -> post(service, 0));
|
||||
assertThrows(IllegalArgumentException.class, () -> post(service, -1));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.createBounty(
|
||||
ISSUER, " ", TARGET, "Target", 1, REWARD, POSTED));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.createBounty(
|
||||
ISSUER, "Issuer", TARGET, " ", 1, REWARD, POSTED));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.createBounty(
|
||||
ISSUER, "Issuer", TARGET, "Target", 1, List.of(), POSTED));
|
||||
assertThrows(NullPointerException.class, () -> service.createBounty(
|
||||
ISSUER, "Issuer", null, "Target", 1, REWARD, POSTED));
|
||||
assertEquals(0, repository.saves);
|
||||
repository.fail = true;
|
||||
assertThrows(IOException.class, () -> post(service, 1));
|
||||
assertTrue(service.state().bounties().isEmpty());
|
||||
assertTrue(repository.state.bounties().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void countsOnlyEligibleDeathsStrictlyAfterPostingAndBeforeExpiry() throws Exception {
|
||||
MemoryRepository repository = new MemoryRepository();
|
||||
QuestService service = new QuestService(repository);
|
||||
Bounty bounty = post(service, 2);
|
||||
for (Instant at : List.of(POSTED.minusNanos(1), POSTED, bounty.expiresAt())) {
|
||||
assertTrue(service.recordDeath(UUID.randomUUID(), TARGET, HUNTER, at).isEmpty());
|
||||
}
|
||||
for (UUID killer : java.util.Arrays.asList(null, TARGET, ISSUER)) {
|
||||
assertTrue(service.recordDeath(UUID.randomUUID(), TARGET, killer, POSTED.plusSeconds(1)).isEmpty());
|
||||
}
|
||||
assertTrue(service.recordDeath(UUID.randomUUID(), UUID.randomUUID(), HUNTER,
|
||||
POSTED.plusSeconds(1)).isEmpty());
|
||||
assertEquals(bounty, service.state().bounties().get(bounty.id()));
|
||||
assertEquals(1, repository.saves);
|
||||
assertTrue(service.recordDeath(UUID.randomUUID(), TARGET, HUNTER,
|
||||
bounty.expiresAt().minusNanos(1)).isEmpty());
|
||||
assertEquals(Map.of(HUNTER, 1), service.state().bounties().get(bounty.id()).progress());
|
||||
}
|
||||
|
||||
@Test
|
||||
void individualProgressAndPersistedDeathIdsProduceOneWinnerClaim() throws Exception {
|
||||
MemoryRepository repository = new MemoryRepository();
|
||||
QuestService service = new QuestService(repository);
|
||||
Bounty bounty = post(service, 2);
|
||||
UUID otherHunter = UUID.randomUUID();
|
||||
UUID firstDeath = UUID.randomUUID();
|
||||
assertTrue(service.recordDeath(firstDeath, TARGET, HUNTER, POSTED.plusSeconds(1)).isEmpty());
|
||||
assertTrue(service.recordDeath(firstDeath, TARGET, otherHunter, POSTED.plusSeconds(1)).isEmpty());
|
||||
assertTrue(service.recordDeath(UUID.randomUUID(), TARGET, otherHunter, POSTED.plusSeconds(2)).isEmpty());
|
||||
QuestService reloaded = new QuestService(repository);
|
||||
assertTrue(reloaded.recordDeath(firstDeath, TARGET, HUNTER, POSTED.plusSeconds(3)).isEmpty());
|
||||
assertEquals(Map.of(HUNTER, 1, otherHunter, 1), reloaded.state().bounties().get(bounty.id()).progress());
|
||||
UUID winningDeath = UUID.randomUUID();
|
||||
List<QuestClaim> earned = reloaded.recordDeath(winningDeath, TARGET, HUNTER, POSTED.plusSeconds(4));
|
||||
assertEquals(1, earned.size());
|
||||
QuestClaim claim = earned.getFirst();
|
||||
assertEquals(HUNTER, claim.ownerId());
|
||||
assertEquals(bounty.id(), claim.questId());
|
||||
assertEquals(REWARD, claim.items());
|
||||
assertEquals(QuestClaimSource.BOUNTY, claim.source());
|
||||
assertEquals(earned, reloaded.claimsFor(HUNTER));
|
||||
Bounty completed = reloaded.state().bounties().get(bounty.id());
|
||||
assertEquals(QuestStatus.COMPLETED, completed.status());
|
||||
assertEquals(HUNTER, completed.winnerId());
|
||||
assertEquals(Map.of(HUNTER, 2, otherHunter, 1), completed.progress());
|
||||
assertTrue(reloaded.activeBounties(POSTED.plusSeconds(5)).isEmpty());
|
||||
assertTrue(reloaded.recordDeath(winningDeath, TARGET, HUNTER, POSTED.plusSeconds(5)).isEmpty());
|
||||
assertTrue(reloaded.recordDeath(UUID.randomUUID(), TARGET, otherHunter, POSTED.plusSeconds(6)).isEmpty());
|
||||
assertEquals(earned, reloaded.claimsFor(HUNTER));
|
||||
assertTrue(reloaded.claimsFor(otherHunter).isEmpty());
|
||||
assertTrue(reloaded.claimsFor(ISSUER).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneDeathAdvancesEveryMatchingBountyAtomicallyAndRetriesAfterSaveFailure() throws Exception {
|
||||
MemoryRepository repository = new MemoryRepository();
|
||||
QuestService service = new QuestService(repository);
|
||||
Bounty first = post(service, 1);
|
||||
Bounty second = post(service, 2);
|
||||
Bounty unrelated = service.createBounty(ISSUER, "Renamed issuer", UUID.randomUUID(),
|
||||
"Other target", 1, REWARD, POSTED);
|
||||
UUID death = UUID.randomUUID();
|
||||
QuestState before = service.state();
|
||||
repository.fail = true;
|
||||
assertThrows(IOException.class, () -> service.recordDeath(death, TARGET, HUNTER, POSTED.plusSeconds(1)));
|
||||
assertSame(before, service.state());
|
||||
assertSame(before, repository.state);
|
||||
repository.fail = false;
|
||||
assertEquals(1, service.recordDeath(death, TARGET, HUNTER, POSTED.plusSeconds(1)).size());
|
||||
assertEquals(QuestStatus.COMPLETED, service.state().bounties().get(first.id()).status());
|
||||
assertEquals(Map.of(HUNTER, 1), service.state().bounties().get(second.id()).progress());
|
||||
assertEquals(Set.of(death), service.state().bounties().get(second.id()).processedDeaths());
|
||||
assertEquals(unrelated, service.state().bounties().get(unrelated.id()));
|
||||
assertEquals(4, repository.saves);
|
||||
assertTrue(service.recordDeath(death, TARGET, HUNTER, POSTED.plusSeconds(2)).isEmpty());
|
||||
assertEquals(4, repository.saves);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerCancellationReturnsWholeRewardDespitePartialProgressAndIsFailureSafe() throws Exception {
|
||||
MemoryRepository repository = new MemoryRepository();
|
||||
QuestService service = new QuestService(repository);
|
||||
Bounty bounty = post(service, 2);
|
||||
service.recordDeath(UUID.randomUUID(), TARGET, HUNTER, POSTED.plusSeconds(1));
|
||||
QuestState before = service.state();
|
||||
assertThrows(IllegalArgumentException.class, () -> service.cancelBounty(bounty.id(), HUNTER, POSTED.plusSeconds(2)));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.cancelBounty(UUID.randomUUID(), ISSUER, POSTED.plusSeconds(2)));
|
||||
assertThrows(IllegalStateException.class, () -> service.cancelBounty(bounty.id(), ISSUER, bounty.expiresAt()));
|
||||
repository.fail = true;
|
||||
assertThrows(IOException.class, () -> service.cancelBounty(bounty.id(), ISSUER, POSTED.plusSeconds(2)));
|
||||
assertSame(before, service.state());
|
||||
repository.fail = false;
|
||||
QuestClaim returned = service.cancelBounty(bounty.id(), ISSUER, POSTED.plusSeconds(2));
|
||||
assertEquals(ISSUER, returned.ownerId());
|
||||
assertEquals(REWARD, returned.items());
|
||||
assertEquals(QuestClaimSource.CANCELLATION, returned.source());
|
||||
assertEquals(QuestStatus.CANCELLED, service.state().bounties().get(bounty.id()).status());
|
||||
assertEquals(Map.of(HUNTER, 1), service.state().bounties().get(bounty.id()).progress());
|
||||
assertTrue(service.activeBounties(POSTED.plusSeconds(3)).isEmpty());
|
||||
assertTrue(service.recordDeath(UUID.randomUUID(), TARGET, HUNTER, POSTED.plusSeconds(3)).isEmpty());
|
||||
assertTrue(service.expire(bounty.expiresAt()).isEmpty());
|
||||
assertThrows(IllegalStateException.class, () -> service.cancelBounty(bounty.id(), ISSUER, POSTED.plusSeconds(3)));
|
||||
assertEquals(List.of(returned), service.claimsFor(ISSUER));
|
||||
assertTrue(service.claimsFor(HUNTER).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void expirySettlesMixedQuestAndBountyStateInOneSaveAndRetriesSafely() throws Exception {
|
||||
MemoryRepository repository = new MemoryRepository();
|
||||
QuestService service = new QuestService(repository);
|
||||
Quest quest = service.create(ISSUER, "Issuer", "STONE", 1, REWARD, POSTED);
|
||||
Bounty bounty = post(service, 2);
|
||||
service.recordDeath(UUID.randomUUID(), TARGET, HUNTER, POSTED.plusSeconds(1));
|
||||
QuestState before = service.state();
|
||||
assertTrue(service.expire(bounty.expiresAt().minusNanos(1)).isEmpty());
|
||||
repository.fail = true;
|
||||
assertThrows(IOException.class, () -> service.expire(bounty.expiresAt()));
|
||||
assertSame(before, service.state());
|
||||
repository.fail = false;
|
||||
List<QuestClaim> returned = service.expire(bounty.expiresAt());
|
||||
assertEquals(2, returned.size());
|
||||
assertEquals(Set.of(quest.id(), bounty.id()), returned.stream().map(QuestClaim::questId)
|
||||
.collect(java.util.stream.Collectors.toSet()));
|
||||
for (QuestClaim claim : returned) {
|
||||
assertEquals(ISSUER, claim.ownerId());
|
||||
assertEquals(REWARD, claim.items());
|
||||
assertEquals(QuestClaimSource.EXPIRATION, claim.source());
|
||||
assertEquals(bounty.expiresAt(), claim.createdAt());
|
||||
}
|
||||
assertEquals(4, repository.saves);
|
||||
assertEquals(QuestStatus.EXPIRED, service.state().bounties().get(bounty.id()).status());
|
||||
assertEquals(QuestStatus.EXPIRED, service.state().quests().get(quest.id()).status());
|
||||
assertEquals(2, service.pendingNotifications(ISSUER).size());
|
||||
assertTrue(service.expire(bounty.expiresAt().plusSeconds(1)).isEmpty());
|
||||
assertTrue(service.recordDeath(UUID.randomUUID(), TARGET, HUNTER, bounty.expiresAt()).isEmpty());
|
||||
assertThrows(IllegalStateException.class, () -> service.cancelBounty(bounty.id(), ISSUER, bounty.expiresAt()));
|
||||
assertTrue(service.claimsFor(HUNTER).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyLegacyTransitionPreservesBountyStateAndBountyClaims() throws Exception {
|
||||
MemoryRepository repository = new MemoryRepository();
|
||||
QuestService service = new QuestService(repository);
|
||||
Bounty active = post(service, 2);
|
||||
Bounty completed = post(service, 1);
|
||||
service.recordDeath(UUID.randomUUID(), TARGET, HUNTER, POSTED.plusSeconds(1));
|
||||
Map<UUID, Bounty> bounties = service.state().bounties();
|
||||
Quest quest = service.create(ISSUER, "Issuer", "STONE", 1, REWARD, POSTED);
|
||||
assertEquals(bounties, service.state().bounties());
|
||||
QuestCompletion completion = service.complete(quest.id(), new EscrowItem("STONE", 1, null), POSTED.plusSeconds(2));
|
||||
assertEquals(bounties, service.state().bounties());
|
||||
Quest cancelled = service.create(ISSUER, "Issuer", "DIRT", 1, REWARD, POSTED);
|
||||
service.cancel(cancelled.id(), ISSUER, POSTED.plusSeconds(2));
|
||||
assertEquals(bounties, service.state().bounties());
|
||||
service.acknowledgeClaim(ISSUER, completion.claim().id());
|
||||
assertEquals(bounties, service.state().bounties());
|
||||
for (IssuerNotification notification : service.pendingNotifications(ISSUER)) {
|
||||
service.markNotificationDelivered(notification.id());
|
||||
}
|
||||
assertEquals(bounties, service.state().bounties());
|
||||
assertEquals(completed.id(), service.claimsFor(HUNTER).getFirst().questId());
|
||||
service.acknowledgeClaim(ISSUER, service.claimsFor(HUNTER).getFirst().id());
|
||||
assertEquals(1, service.claimsFor(HUNTER).size());
|
||||
service.acknowledgeClaim(HUNTER, service.claimsFor(HUNTER).getFirst().id());
|
||||
assertTrue(service.claimsFor(HUNTER).isEmpty());
|
||||
assertEquals(bounties, service.state().bounties());
|
||||
assertEquals(QuestStatus.ACTIVE, service.state().bounties().get(active.id()).status());
|
||||
assertEquals(QuestStatus.COMPLETED, service.state().bounties().get(completed.id()).status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotReadsNeverWaitForBlockedPersistence() throws Exception {
|
||||
java.util.concurrent.CountDownLatch saving = new java.util.concurrent.CountDownLatch(1);
|
||||
java.util.concurrent.CountDownLatch release = new java.util.concurrent.CountDownLatch(1);
|
||||
QuestService service = new QuestService(new QuestRepository() {
|
||||
@Override public QuestState load() { return QuestState.empty(); }
|
||||
@Override public void save(QuestState candidate) throws IOException {
|
||||
saving.countDown();
|
||||
try {
|
||||
if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) {
|
||||
throw new IOException("test save timed out");
|
||||
}
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException(exception);
|
||||
}
|
||||
}
|
||||
});
|
||||
try (var executor = java.util.concurrent.Executors.newFixedThreadPool(2)) {
|
||||
var write = executor.submit(() -> post(service, 1));
|
||||
assertTrue(saving.await(2, java.util.concurrent.TimeUnit.SECONDS));
|
||||
try {
|
||||
var read = executor.submit(() -> {
|
||||
assertEquals(QuestState.empty(), service.state());
|
||||
assertTrue(service.claimsFor(ISSUER).isEmpty());
|
||||
assertTrue(service.activeQuests(POSTED).isEmpty());
|
||||
assertTrue(service.activeBounties(POSTED).isEmpty());
|
||||
assertTrue(service.pendingNotifications(ISSUER).isEmpty());
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> service.completionCandidate(UUID.randomUUID(), POSTED));
|
||||
return true;
|
||||
});
|
||||
assertTrue(read.get(1, java.util.concurrent.TimeUnit.SECONDS));
|
||||
} finally {
|
||||
release.countDown();
|
||||
}
|
||||
assertNotNull(write.get());
|
||||
assertEquals(1, service.state().bounties().size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentHuntersHaveExactlyOneWinnerAndDuplicateDeathHasOneEffect() throws Exception {
|
||||
try (var executor = java.util.concurrent.Executors.newFixedThreadPool(2)) {
|
||||
for (int iteration = 0; iteration < 20; iteration++) {
|
||||
MemoryRepository repository = new MemoryRepository();
|
||||
QuestService service = new QuestService(repository);
|
||||
Bounty bounty = post(service, 1);
|
||||
UUID other = UUID.randomUUID();
|
||||
UUID death = UUID.randomUUID();
|
||||
var start = new java.util.concurrent.CyclicBarrier(2);
|
||||
var first = executor.submit(() -> {
|
||||
start.await();
|
||||
return service.recordDeath(death, TARGET, HUNTER, POSTED.plusSeconds(1));
|
||||
});
|
||||
var second = executor.submit(() -> {
|
||||
start.await();
|
||||
return service.recordDeath(death, TARGET, other, POSTED.plusSeconds(1));
|
||||
});
|
||||
assertEquals(1, first.get().size() + second.get().size());
|
||||
Bounty won = service.state().bounties().get(bounty.id());
|
||||
assertEquals(Set.of(death), won.processedDeaths());
|
||||
assertEquals(Map.of(won.winnerId(), 1), won.progress());
|
||||
assertEquals(1, service.claimsFor(HUNTER).size() + service.claimsFor(other).size());
|
||||
assertEquals(REWARD, service.claimsFor(won.winnerId()).getFirst().items());
|
||||
assertEquals(service.state(), repository.state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void distinctSimultaneousDeathsCannotCreateTwoWinners() throws Exception {
|
||||
MemoryRepository repository = new MemoryRepository();
|
||||
QuestService service = new QuestService(repository);
|
||||
Bounty bounty = post(service, 2);
|
||||
UUID other = UUID.randomUUID();
|
||||
service.recordDeath(UUID.randomUUID(), TARGET, HUNTER, POSTED.plusSeconds(1));
|
||||
service.recordDeath(UUID.randomUUID(), TARGET, other, POSTED.plusSeconds(1));
|
||||
try (var executor = java.util.concurrent.Executors.newFixedThreadPool(2)) {
|
||||
var start = new java.util.concurrent.CyclicBarrier(2);
|
||||
var first = executor.submit(() -> {
|
||||
start.await();
|
||||
return service.recordDeath(UUID.randomUUID(), TARGET, HUNTER, POSTED.plusSeconds(2));
|
||||
});
|
||||
var second = executor.submit(() -> {
|
||||
start.await();
|
||||
return service.recordDeath(UUID.randomUUID(), TARGET, other, POSTED.plusSeconds(2));
|
||||
});
|
||||
assertEquals(1, first.get().size() + second.get().size());
|
||||
}
|
||||
Bounty won = service.state().bounties().get(bounty.id());
|
||||
assertEquals(QuestStatus.COMPLETED, won.status());
|
||||
assertEquals(3, won.processedDeaths().size());
|
||||
assertEquals(1, service.claimsFor(HUNTER).size() + service.claimsFor(other).size());
|
||||
assertThrows(IllegalStateException.class, () -> service.cancelBounty(bounty.id(), ISSUER, POSTED.plusSeconds(3)));
|
||||
assertTrue(service.expire(bounty.expiresAt()).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancellationExpiryAndCompletionRaceDistributesEscrowOnlyOnce() throws Exception {
|
||||
try (var executor = java.util.concurrent.Executors.newFixedThreadPool(3)) {
|
||||
for (int iteration = 0; iteration < 20; iteration++) {
|
||||
MemoryRepository repository = new MemoryRepository();
|
||||
QuestService service = new QuestService(repository);
|
||||
Bounty bounty = post(service, 1);
|
||||
var start = new java.util.concurrent.CyclicBarrier(3);
|
||||
var finish = executor.submit(() -> {
|
||||
start.await();
|
||||
return service.recordDeath(UUID.randomUUID(), TARGET, HUNTER,
|
||||
bounty.expiresAt().minusNanos(1));
|
||||
});
|
||||
var cancel = executor.submit(() -> {
|
||||
start.await();
|
||||
try {
|
||||
return service.cancelBounty(bounty.id(), ISSUER, bounty.expiresAt().minusNanos(1));
|
||||
} catch (IllegalStateException lostRace) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
var expire = executor.submit(() -> {
|
||||
start.await();
|
||||
return service.expire(bounty.expiresAt());
|
||||
});
|
||||
int payouts = finish.get().size() + (cancel.get() == null ? 0 : 1) + expire.get().size();
|
||||
assertEquals(1, payouts);
|
||||
assertEquals(1, service.claimsFor(HUNTER).size() + service.claimsFor(ISSUER).size());
|
||||
Bounty settled = service.state().bounties().get(bounty.id());
|
||||
UUID payee = settled.status() == QuestStatus.COMPLETED ? HUNTER : ISSUER;
|
||||
assertEquals(REWARD, service.claimsFor(payee).getFirst().items());
|
||||
assertTrue(service.activeBounties(POSTED.plusSeconds(1)).isEmpty());
|
||||
assertEquals(service.state(), repository.state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void progressOnlySaveFailureRetainsDeathForRetryAndClaimAcknowledgementFailureRetainsReward() throws Exception {
|
||||
MemoryRepository repository = new MemoryRepository();
|
||||
QuestService service = new QuestService(repository);
|
||||
Bounty bounty = post(service, 2);
|
||||
UUID death = UUID.randomUUID();
|
||||
repository.fail = true;
|
||||
assertThrows(IOException.class, () -> service.recordDeath(death, TARGET, HUNTER, POSTED.plusSeconds(1)));
|
||||
assertEquals(bounty, service.state().bounties().get(bounty.id()));
|
||||
repository.fail = false;
|
||||
service.recordDeath(death, TARGET, HUNTER, POSTED.plusSeconds(1));
|
||||
QuestClaim earned = service.recordDeath(UUID.randomUUID(), TARGET, HUNTER, POSTED.plusSeconds(2)).getFirst();
|
||||
QuestState before = service.state();
|
||||
repository.fail = true;
|
||||
assertThrows(IOException.class, () -> service.acknowledgeClaim(HUNTER, earned.id()));
|
||||
assertSame(before, service.state());
|
||||
assertEquals(List.of(earned), new QuestService(repository).claimsFor(HUNTER));
|
||||
repository.fail = false;
|
||||
service.acknowledgeClaim(HUNTER, earned.id());
|
||||
service.acknowledgeClaim(HUNTER, earned.id());
|
||||
QuestService reloaded = new QuestService(repository);
|
||||
assertTrue(reloaded.claimsFor(HUNTER).isEmpty());
|
||||
assertEquals(QuestStatus.COMPLETED, reloaded.state().bounties().get(bounty.id()).status());
|
||||
assertTrue(reloaded.recordDeath(death, TARGET, HUNTER, POSTED.plusSeconds(3)).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void namesDoNotControlEligibilityAndIssuerMayPostAgainstTheirOwnUuid() throws Exception {
|
||||
QuestService service = new QuestService(new MemoryRepository());
|
||||
Bounty first = post(service, 2);
|
||||
Bounty renamed = service.createBounty(ISSUER, "Renamed issuer", TARGET, "Renamed target", 2, REWARD, POSTED);
|
||||
Bounty selfTarget = service.createBounty(TARGET, "Target", TARGET, "Target", 1, REWARD, POSTED);
|
||||
service.recordDeath(UUID.randomUUID(), TARGET, ISSUER, POSTED.plusSeconds(1));
|
||||
assertTrue(service.state().bounties().get(first.id()).progress().isEmpty());
|
||||
assertTrue(service.state().bounties().get(renamed.id()).progress().isEmpty());
|
||||
assertEquals(ISSUER, service.state().bounties().get(selfTarget.id()).winnerId());
|
||||
service.recordDeath(UUID.randomUUID(), TARGET, HUNTER, POSTED.plusSeconds(2));
|
||||
assertEquals(Map.of(HUNTER, 1), service.state().bounties().get(first.id()).progress());
|
||||
assertEquals(Map.of(HUNTER, 1), service.state().bounties().get(renamed.id()).progress());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bountyAndStateDefensivelyCopyCollectionsAndValidateMapKeys() throws Exception {
|
||||
QuestService service = new QuestService(new MemoryRepository());
|
||||
Bounty posted = post(service, 2);
|
||||
Map<UUID, Integer> progress = new java.util.LinkedHashMap<>(Map.of(HUNTER, 1));
|
||||
Set<UUID> deaths = new java.util.HashSet<>(Set.of(UUID.randomUUID()));
|
||||
List<EscrowItem> reward = new ArrayList<>(REWARD);
|
||||
Bounty snapshot = new Bounty(posted.id(), ISSUER, "Issuer", TARGET, "Target", 2,
|
||||
reward, POSTED, posted.expiresAt(), QuestStatus.ACTIVE, progress, deaths, null);
|
||||
Map<UUID, Bounty> bounties = new java.util.LinkedHashMap<>(Map.of(snapshot.id(), snapshot));
|
||||
QuestState state = new QuestState(Map.of(), Map.of(), Map.of(), bounties);
|
||||
bounties.clear();
|
||||
progress.clear();
|
||||
deaths.clear();
|
||||
reward.clear();
|
||||
assertEquals(Map.of(HUNTER, 1), snapshot.progress());
|
||||
assertEquals(1, snapshot.processedDeaths().size());
|
||||
assertEquals(REWARD, snapshot.reward());
|
||||
assertEquals(Map.of(snapshot.id(), snapshot), state.bounties());
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new QuestState(Map.of(), Map.of(), Map.of(), Map.of(UUID.randomUUID(), snapshot)));
|
||||
}
|
||||
|
||||
private static Bounty post(QuestService service, int requiredKills) throws IOException {
|
||||
return service.createBounty(ISSUER, "Issuer", TARGET, "Target", requiredKills, REWARD, POSTED);
|
||||
}
|
||||
|
||||
private static final class MemoryRepository implements QuestRepository {
|
||||
private QuestState state = QuestState.empty();
|
||||
private int saves;
|
||||
private boolean fail;
|
||||
@Override public QuestState load() { return state; }
|
||||
@Override public void save(QuestState candidate) throws IOException {
|
||||
if (fail) {
|
||||
throw new IOException("disk full");
|
||||
}
|
||||
state = candidate;
|
||||
saves++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import org.bukkit.OfflinePlayer;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.profile.PlayerProfile;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class BountyTargetResolverTest {
|
||||
private final Server server = mock(Server.class);
|
||||
private final Queue<Runnable> main = new ArrayDeque<>();
|
||||
private final BountyTargetResolver resolver = new BountyTargetResolver(server, main::add);
|
||||
private final List<BountyTargetResolver.Target> results = new ArrayList<>();
|
||||
private final List<String> failures = new ArrayList<>();
|
||||
|
||||
@Test void onlineAndCachedNamesDoNotNeedNetworkAndOfflinePlayersAreEligible() {
|
||||
Player online = mock(Player.class);
|
||||
UUID id = UUID.randomUUID();
|
||||
when(online.getUniqueId()).thenReturn(id);
|
||||
when(online.getName()).thenReturn("Online");
|
||||
when(server.getPlayerExact("Online")).thenReturn(online);
|
||||
OfflinePlayer cached = mock(OfflinePlayer.class);
|
||||
UUID offlineId = UUID.randomUUID();
|
||||
when(cached.getUniqueId()).thenReturn(offlineId);
|
||||
when(cached.getName()).thenReturn("Offline");
|
||||
when(server.getOfflinePlayerIfCached("Offline")).thenReturn(cached);
|
||||
resolver.resolve(" Online ", results::add, failures::add);
|
||||
resolver.resolve("Offline", results::add, failures::add);
|
||||
drain();
|
||||
assertEquals(List.of(new BountyTargetResolver.Target(id, "Online"), new BountyTargetResolver.Target(offlineId, "Offline")), results);
|
||||
verify(server, never()).getOfflinePlayer(anyString());
|
||||
verify(server, never()).createPlayerProfile(anyString());
|
||||
}
|
||||
|
||||
@Test void uncachedNameAndUuidUseNonblockingProfileUpdateAndMarshalCompletionToMain() throws Exception {
|
||||
for (boolean byId : List.of(false, true)) {
|
||||
UUID id = UUID.randomUUID();
|
||||
String input = byId ? id.toString() : "Unknown";
|
||||
PlayerProfile initial = mock(PlayerProfile.class);
|
||||
PlayerProfile updated = mock(PlayerProfile.class);
|
||||
when(updated.getUniqueId()).thenReturn(id);
|
||||
when(updated.getName()).thenReturn("Resolved");
|
||||
CompletableFuture<PlayerProfile> lookup = new CompletableFuture<>();
|
||||
doReturn(lookup).when(initial).update();
|
||||
if (byId) { when(server.createPlayerProfile(id)).thenReturn(initial); }
|
||||
else { when(server.createPlayerProfile(input)).thenReturn(initial); }
|
||||
resolver.resolve(input, results::add, failures::add);
|
||||
verify(initial).update();
|
||||
assertTrue(results.isEmpty());
|
||||
Thread completion = new Thread(() -> lookup.complete(updated));
|
||||
completion.start(); completion.join();
|
||||
assertTrue(results.isEmpty(), "Profile completion must not call player/UI off-thread");
|
||||
verify(updated, never()).getName();
|
||||
drain();
|
||||
assertEquals(List.of(new BountyTargetResolver.Target(id, "Resolved")), results);
|
||||
results.clear();
|
||||
}
|
||||
assertTrue(failures.isEmpty());
|
||||
verify(server, never()).getOfflinePlayer(anyString());
|
||||
verify(server, never()).getOfflinePlayer(any(UUID.class));
|
||||
}
|
||||
|
||||
@Test void invalidAndUnresolvedTargetsNeverReachPosting() {
|
||||
for (String invalid : List.of("", "not a name", "bad/target", "aaaaaaaaaaaaaaaaa")) {
|
||||
resolver.resolve(invalid, results::add, failures::add);
|
||||
}
|
||||
PlayerProfile profile = mock(PlayerProfile.class);
|
||||
when(server.createPlayerProfile("Missing")).thenReturn(profile);
|
||||
doReturn(CompletableFuture.completedFuture(profile)).when(profile).update();
|
||||
resolver.resolve("Missing", results::add, failures::add);
|
||||
drain();
|
||||
assertTrue(results.isEmpty());
|
||||
assertEquals(5, failures.size());
|
||||
}
|
||||
private void drain() { while (!main.isEmpty()) { main.remove().run(); } }
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.clearInvocations;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class BukkitIssuerNotifierTest {
|
||||
@Test
|
||||
void onlineIssuerIsNotifiedImmediatelyAndDurablePendingStateIsCleared() throws Exception {
|
||||
Repository repository = new Repository();
|
||||
QuestService service = completedService(repository);
|
||||
UUID issuer = service.state().notifications().values().iterator().next().recipientId();
|
||||
Server server = mock(Server.class);
|
||||
Player player = mock(Player.class);
|
||||
when(server.getPlayer(issuer)).thenReturn(player);
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
when(player.getUniqueId()).thenReturn(issuer);
|
||||
BukkitIssuerNotifier notifier = new BukkitIssuerNotifier(
|
||||
service, server, Logger.getAnonymousLogger()
|
||||
);
|
||||
|
||||
notifier.notifyIfOnline(issuer);
|
||||
|
||||
verify(player).sendMessage(contains("can be claimed at a quest board"));
|
||||
assertTrue(service.state().notifications().isEmpty());
|
||||
assertTrue(repository.state.notifications().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedNotificationAcknowledgementRemainsPendingForRetry() throws Exception {
|
||||
Repository repository = new Repository();
|
||||
QuestService service = completedService(repository);
|
||||
IssuerNotification notification = service.state().notifications().values().iterator().next();
|
||||
Server server = mock(Server.class);
|
||||
Player player = mock(Player.class);
|
||||
when(server.getPlayer(notification.recipientId())).thenReturn(player);
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
when(player.getUniqueId()).thenReturn(notification.recipientId());
|
||||
BukkitIssuerNotifier notifier = new BukkitIssuerNotifier(
|
||||
service, server, Logger.getAnonymousLogger()
|
||||
);
|
||||
repository.fail = true;
|
||||
|
||||
notifier.notifyIfOnline(notification.recipientId());
|
||||
assertEquals(1, service.pendingNotifications(notification.recipientId()).size());
|
||||
|
||||
repository.fail = false;
|
||||
notifier.notifyIfOnline(notification.recipientId());
|
||||
assertTrue(service.pendingNotifications(notification.recipientId()).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void offlineNotificationRemainsDurableUntilNextLogin() throws Exception {
|
||||
Repository repository = new Repository();
|
||||
QuestService service = completedService(repository);
|
||||
IssuerNotification notification = service.state().notifications().values().iterator().next();
|
||||
Server server = mock(Server.class);
|
||||
when(server.getPlayer(notification.recipientId())).thenReturn(null);
|
||||
BukkitIssuerNotifier notifier = new BukkitIssuerNotifier(
|
||||
service, server, Logger.getAnonymousLogger()
|
||||
);
|
||||
|
||||
notifier.notifyIfOnline(notification.recipientId());
|
||||
assertEquals(1, service.state().notifications().size());
|
||||
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(notification.recipientId());
|
||||
notifier.deliver(player);
|
||||
|
||||
verify(player).sendMessage(notification.message());
|
||||
assertTrue(service.state().notifications().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyLoginReportsAvailableQuestsWithNaturalWordingAndBoardHint() throws Exception {
|
||||
QuestService service = new QuestService(new Repository());
|
||||
Instant now = Instant.EPOCH.plusSeconds(604800);
|
||||
BukkitIssuerNotifier notifier = new BukkitIssuerNotifier(
|
||||
service, mock(Server.class), Logger.getAnonymousLogger(),
|
||||
Clock.fixed(now, ZoneOffset.UTC)
|
||||
);
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
PlayerJoinEvent event = mock(PlayerJoinEvent.class);
|
||||
when(event.getPlayer()).thenReturn(player);
|
||||
String hint = " Visit the quest board to accept or create a quest.";
|
||||
|
||||
// Expired at exactly login time, even before the expiry task runs.
|
||||
createQuest(service, Instant.EPOCH);
|
||||
Quest completed = createQuest(service, now);
|
||||
service.complete(completed.id(), new EscrowItem("STONE", 1, null), now);
|
||||
Quest cancelled = createQuest(service, now);
|
||||
service.cancel(cancelled.id(), cancelled.issuerId(), now);
|
||||
notifier.onPlayerJoin(event);
|
||||
verify(player).sendMessage("No quests are currently available." + hint);
|
||||
|
||||
createQuest(service, now);
|
||||
notifier.onPlayerJoin(event);
|
||||
verify(player).sendMessage("1 quest is currently available." + hint);
|
||||
|
||||
createQuest(service, now);
|
||||
notifier.onPlayerJoin(event);
|
||||
notifier.onPlayerJoin(event);
|
||||
verify(player, times(2)).sendMessage("2 quests are currently available." + hint);
|
||||
verify(player, never()).sendMessage(contains("items waiting"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void claimsAreRemindedOnEveryLoginAcrossReloadsUntilAllCollected() throws Exception {
|
||||
Repository repository = new Repository();
|
||||
QuestService service = completedService(repository);
|
||||
UUID issuer = service.state().notifications().values().iterator().next().recipientId();
|
||||
Quest cancelled = service.create(
|
||||
issuer, "Issuer", "STONE", 1,
|
||||
List.of(new EscrowItem("DIAMOND", 1, null)), Instant.EPOCH
|
||||
);
|
||||
service.cancel(cancelled.id(), issuer, Instant.EPOCH.plusSeconds(1));
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(issuer);
|
||||
PlayerJoinEvent event = mock(PlayerJoinEvent.class);
|
||||
when(event.getPlayer()).thenReturn(player);
|
||||
String reminder = "You have items waiting to be claimed. "
|
||||
+ "Visit the quest board to collect them.";
|
||||
BukkitIssuerNotifier notifier = new BukkitIssuerNotifier(
|
||||
service, mock(Server.class), Logger.getAnonymousLogger()
|
||||
);
|
||||
notifier.onPlayerJoin(event);
|
||||
notifier.onPlayerJoin(event);
|
||||
verify(player, times(2)).sendMessage(reminder);
|
||||
assertTrue(service.pendingNotifications(issuer).isEmpty());
|
||||
assertEquals(2, service.claimsFor(issuer).size());
|
||||
|
||||
service = new QuestService(repository);
|
||||
notifier = new BukkitIssuerNotifier(service, mock(Server.class), Logger.getAnonymousLogger());
|
||||
notifier.onPlayerJoin(event);
|
||||
verify(player, times(3)).sendMessage(reminder);
|
||||
service.acknowledgeClaim(issuer, service.claimsFor(issuer).getFirst().id());
|
||||
notifier.onPlayerJoin(event);
|
||||
verify(player, times(4)).sendMessage(reminder);
|
||||
|
||||
clearInvocations(player);
|
||||
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
notifier.onPlayerJoin(event);
|
||||
verify(player, never()).sendMessage(reminder);
|
||||
when(player.getUniqueId()).thenReturn(issuer);
|
||||
service.acknowledgeClaim(issuer, service.claimsFor(issuer).getFirst().id());
|
||||
notifier.onPlayerJoin(event);
|
||||
verify(player, never()).sendMessage(reminder);
|
||||
}
|
||||
|
||||
private static Quest createQuest(QuestService service, Instant now) throws Exception {
|
||||
return service.create(
|
||||
UUID.randomUUID(), "Issuer", "STONE", 1,
|
||||
List.of(new EscrowItem("DIAMOND", 1, null)), now
|
||||
);
|
||||
}
|
||||
|
||||
private static QuestService completedService(Repository repository) throws Exception {
|
||||
QuestService service = new QuestService(repository);
|
||||
Quest quest = service.create(
|
||||
UUID.randomUUID(), "Issuer", "STONE", 1,
|
||||
List.of(new EscrowItem("DIAMOND", 1, null)), Instant.EPOCH
|
||||
);
|
||||
service.complete(
|
||||
quest.id(), new EscrowItem("STONE", 1, null), Instant.EPOCH.plusSeconds(1)
|
||||
);
|
||||
return service;
|
||||
}
|
||||
|
||||
private static final class Repository implements QuestRepository {
|
||||
private QuestState state = QuestState.empty();
|
||||
private boolean fail;
|
||||
@Override public QuestState load() { return state; }
|
||||
@Override
|
||||
public void save(QuestState candidate) throws java.io.IOException {
|
||||
if (fail) {
|
||||
throw new java.io.IOException("disk full");
|
||||
}
|
||||
state = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.bukkit.Material;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
final class BukkitItemMaterialCatalogTest {
|
||||
private final BukkitItemMaterialCatalog catalog = new BukkitItemMaterialCatalog();
|
||||
|
||||
private MockedStatic<Material> materials;
|
||||
|
||||
@BeforeEach
|
||||
void provideServerMaterialProperties() {
|
||||
// Purpur delegates material properties to server-only registries.
|
||||
String[] names = {"SHULKER_SHELL", "SHULKER_BOX", "STONE", "AIR", "CAVE_AIR", "VOID_AIR", "WATER", "FIRE"};
|
||||
Material[] fixtures = new Material[names.length];
|
||||
for (int i = 0; i < names.length; i++) {
|
||||
Material material = Mockito.mock(Material.class);
|
||||
Mockito.when(material.name()).thenReturn(names[i]);
|
||||
Mockito.when(material.isItem()).thenReturn(i <= 3);
|
||||
Mockito.when(material.isBlock()).thenReturn(i != 0);
|
||||
Mockito.when(material.isAir()).thenReturn(i >= 3 && i <= 5);
|
||||
fixtures[i] = material;
|
||||
}
|
||||
materials = Mockito.mockStatic(Material.class);
|
||||
materials.when(Material::values).thenReturn(fixtures);
|
||||
for (int i = 0; i < names.length; i++) {
|
||||
String name = names[i];
|
||||
Material material = fixtures[i];
|
||||
materials.when(() -> Material.matchMaterial(name)).thenReturn(material);
|
||||
materials.when(() -> Material.matchMaterial(name.toLowerCase(java.util.Locale.ROOT)))
|
||||
.thenReturn(material);
|
||||
}
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void closeMaterialFixtures() {
|
||||
materials.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsShellsAndExistingBlockItems() {
|
||||
assertEquals(Optional.of("SHULKER_SHELL"), catalog.normalizeItem("shulker_shell"));
|
||||
assertEquals(Optional.of("SHULKER_BOX"), catalog.normalizeItem("shulker_box"));
|
||||
assertEquals(Optional.of("STONE"), catalog.normalizeItem("stone"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAirInvalidAndNonItemMaterials() {
|
||||
for (String input : new String[] {"AIR", "CAVE_AIR", "VOID_AIR", "WATER", "FIRE", "invalid", ""}) {
|
||||
assertTrue(catalog.normalizeItem(input).isEmpty(), input);
|
||||
}
|
||||
assertTrue(catalog.normalizeItem(null).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsPersistsAndSettlesShellRequest(@org.junit.jupiter.api.io.TempDir java.nio.file.Path directory)
|
||||
throws Exception {
|
||||
QuestRepository repository = new YamlQuestRepository(directory.resolve("quests.yml"));
|
||||
QuestService service = new QuestService(repository);
|
||||
org.bukkit.entity.Player player = Mockito.mock(org.bukkit.entity.Player.class);
|
||||
java.util.UUID issuer = java.util.UUID.randomUUID();
|
||||
Mockito.when(player.getUniqueId()).thenReturn(issuer);
|
||||
Mockito.when(player.getName()).thenReturn("Issuer");
|
||||
EscrowItem reward = new EscrowItem("DIAMOND", 1, null);
|
||||
HeldRewardInventory inventory = ignored -> new HeldRewardInventory.RemovedReward() {
|
||||
@Override public EscrowItem item() { return reward; }
|
||||
@Override public void rollback() { throw new AssertionError("Unexpected rollback"); }
|
||||
};
|
||||
QuestCreationController controller = new QuestCreationController(service, catalog, inventory);
|
||||
assertTrue(controller.suggestItemMaterials("shulker_").contains("SHULKER_SHELL"));
|
||||
Quest quest = controller.create(player, "shulker_shell", 16, java.time.Instant.EPOCH);
|
||||
service = new QuestService(repository);
|
||||
EscrowItem delivery = new EscrowItem("SHULKER_SHELL", 16, null);
|
||||
QuestCompletion completion = service.complete(
|
||||
quest.id(), delivery, java.time.Instant.EPOCH.plusSeconds(1)
|
||||
);
|
||||
assertEquals(java.util.List.of(reward), completion.reward());
|
||||
service = new QuestService(repository);
|
||||
QuestClaim claim = service.claimsFor(issuer).getFirst();
|
||||
assertEquals(java.util.List.of(delivery), claim.items());
|
||||
assertEquals(QuestStatus.COMPLETED, service.state().quests().get(quest.id()).status());
|
||||
service.acknowledgeClaim(issuer, claim.id());
|
||||
assertTrue(new QuestService(repository).claimsFor(issuer).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void suggestsShellsAndBoxesButNotNonItems() {
|
||||
assertTrue(catalog.suggest("shulker_").contains("SHULKER_SHELL"));
|
||||
assertTrue(catalog.suggest("shulker_").contains("SHULKER_BOX"));
|
||||
assertTrue(catalog.suggest("WATER").stream().noneMatch("WATER"::equals));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.BlockState;
|
||||
import org.bukkit.block.Sign;
|
||||
import org.bukkit.block.data.type.WallSign;
|
||||
import org.bukkit.block.sign.Side;
|
||||
import org.bukkit.block.sign.SignSide;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
final class BukkitPhysicalBoardWorldTest {
|
||||
@Test
|
||||
void configuresWallSignFacingAndDecorativeObfuscatedText() {
|
||||
UUID worldId = UUID.randomUUID();
|
||||
BoardId location = new BoardId(worldId, 1, 65, 2);
|
||||
World world = mock(World.class);
|
||||
Block block = mock(Block.class);
|
||||
WallSign wallSign = mock(WallSign.class);
|
||||
Sign sign = mock(Sign.class);
|
||||
SignSide signSide = mock(SignSide.class);
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
when(world.getBlockAt(1, 65, 2)).thenReturn(block);
|
||||
when(block.getBlockData()).thenReturn(wallSign);
|
||||
when(block.getState()).thenReturn(sign);
|
||||
when(sign.getSide(Side.FRONT)).thenReturn(signSide);
|
||||
when(sign.update(true, false)).thenReturn(true);
|
||||
BukkitPhysicalBoardWorld adapter = new BukkitPhysicalBoardWorld(world);
|
||||
|
||||
adapter.place(
|
||||
new PhysicalBoardPlan.Placement(location, "OAK_WALL_SIGN"),
|
||||
BoardFacing.NORTH
|
||||
);
|
||||
|
||||
verify(block).setType(Material.OAK_WALL_SIGN, false);
|
||||
verify(wallSign).setFacing(org.bukkit.block.BlockFace.NORTH);
|
||||
verify(block).setBlockData(wallSign, false);
|
||||
ArgumentCaptor<Component> text = ArgumentCaptor.forClass(Component.class);
|
||||
verify(signSide, times(4)).line(any(Integer.class), text.capture());
|
||||
assertEquals(4, text.getAllValues().size());
|
||||
assertTrue(text.getAllValues().stream().allMatch(component ->
|
||||
component.decoration(TextDecoration.OBFUSCATED) == TextDecoration.State.TRUE
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchesOnlyAnExactDecorativeSignForLegacyIdentification() {
|
||||
UUID worldId = UUID.randomUUID();
|
||||
BoardId location = new BoardId(worldId, 1, 65, 2);
|
||||
World world = mock(World.class);
|
||||
Block block = mock(Block.class);
|
||||
WallSign wallSign = mock(WallSign.class);
|
||||
Sign sign = mock(Sign.class);
|
||||
SignSide signSide = mock(SignSide.class);
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
when(world.getBlockAt(1, 65, 2)).thenReturn(block);
|
||||
when(block.getType()).thenReturn(Material.OAK_WALL_SIGN);
|
||||
when(block.getBlockData()).thenReturn(wallSign);
|
||||
when(block.getState()).thenReturn(sign);
|
||||
when(wallSign.getFacing()).thenReturn(org.bukkit.block.BlockFace.NORTH);
|
||||
when(sign.getSide(Side.FRONT)).thenReturn(signSide);
|
||||
List<Component> decorative = BukkitPhysicalBoardWorld.signLines(
|
||||
PhysicalBoardSignKind.DECORATIVE
|
||||
);
|
||||
for (int line = 0; line < decorative.size(); line++) {
|
||||
when(signSide.line(line)).thenReturn(decorative.get(line));
|
||||
}
|
||||
BukkitPhysicalBoardWorld adapter = new BukkitPhysicalBoardWorld(world);
|
||||
PhysicalBoardPlan.Placement placement = new PhysicalBoardPlan.Placement(
|
||||
location, "OAK_WALL_SIGN", PhysicalBoardSignKind.DECORATIVE
|
||||
);
|
||||
|
||||
assertTrue(adapter.matches(placement, BoardFacing.NORTH));
|
||||
assertFalse(adapter.matches(placement, BoardFacing.SOUTH));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readableSignsGlowWhileDecorativeSignsDoNot() {
|
||||
UUID worldId = UUID.randomUUID();
|
||||
BoardId location = new BoardId(worldId, 1, 65, 2);
|
||||
World world = mock(World.class);
|
||||
Block block = mock(Block.class);
|
||||
WallSign wallSign = mock(WallSign.class);
|
||||
Sign sign = mock(Sign.class);
|
||||
SignSide signSide = mock(SignSide.class);
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
when(world.getBlockAt(1, 65, 2)).thenReturn(block);
|
||||
when(block.getBlockData()).thenReturn(wallSign);
|
||||
when(block.getState()).thenReturn(sign);
|
||||
when(sign.getSide(Side.FRONT)).thenReturn(signSide);
|
||||
when(sign.update(true, false)).thenReturn(true);
|
||||
BukkitPhysicalBoardWorld adapter = new BukkitPhysicalBoardWorld(world);
|
||||
|
||||
adapter.place(new PhysicalBoardPlan.Placement(
|
||||
location, "OAK_WALL_SIGN", PhysicalBoardSignKind.TITLE
|
||||
), BoardFacing.NORTH);
|
||||
verify(signSide).setGlowingText(true);
|
||||
|
||||
adapter.place(new PhysicalBoardPlan.Placement(
|
||||
location, "OAK_WALL_SIGN", PhysicalBoardSignKind.DECORATIVE
|
||||
), BoardFacing.NORTH);
|
||||
verify(signSide).setGlowingText(false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rendersReadableTitleAndInstructionText() {
|
||||
assertEquals(
|
||||
List.of(Component.text("Quest"), Component.text("Board"),
|
||||
Component.empty(), Component.empty()),
|
||||
placedLines(PhysicalBoardSignKind.TITLE)
|
||||
);
|
||||
assertEquals(
|
||||
List.of(Component.text("Right-click"), Component.text("a sign below"),
|
||||
Component.text("to browse"), Component.text("quests")),
|
||||
placedLines(PhysicalBoardSignKind.INSTRUCTION)
|
||||
);
|
||||
}
|
||||
|
||||
private static List<Component> placedLines(PhysicalBoardSignKind kind) {
|
||||
UUID worldId = UUID.randomUUID();
|
||||
BoardId location = new BoardId(worldId, 1, 65, 2);
|
||||
World world = mock(World.class);
|
||||
Block block = mock(Block.class);
|
||||
WallSign wallSign = mock(WallSign.class);
|
||||
Sign sign = mock(Sign.class);
|
||||
SignSide signSide = mock(SignSide.class);
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
when(world.getBlockAt(1, 65, 2)).thenReturn(block);
|
||||
when(block.getBlockData()).thenReturn(wallSign);
|
||||
when(block.getState()).thenReturn(sign);
|
||||
when(sign.getSide(Side.FRONT)).thenReturn(signSide);
|
||||
when(sign.update(true, false)).thenReturn(true);
|
||||
|
||||
new BukkitPhysicalBoardWorld(world).place(
|
||||
new PhysicalBoardPlan.Placement(location, "OAK_WALL_SIGN", kind),
|
||||
BoardFacing.NORTH
|
||||
);
|
||||
|
||||
ArgumentCaptor<Component> text = ArgumentCaptor.forClass(Component.class);
|
||||
verify(signSide, times(4)).line(any(Integer.class), text.capture());
|
||||
return text.getAllValues();
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoresCapturedBukkitBlockState() {
|
||||
UUID worldId = UUID.randomUUID();
|
||||
BoardId location = new BoardId(worldId, 1, 65, 2);
|
||||
World world = mock(World.class);
|
||||
BlockState snapshot = mock(BlockState.class);
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
when(snapshot.update(true, false)).thenReturn(true);
|
||||
|
||||
new BukkitPhysicalBoardWorld(world).restore(location, snapshot);
|
||||
|
||||
verify(snapshot).update(true, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Item;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class BukkitQuestClaimInventoryTest {
|
||||
@Test
|
||||
void inventoryOverflowIsDroppedAtFeetWithOwnerProtection() {
|
||||
UUID owner = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||
World world = mock(World.class);
|
||||
Location feet = mock(Location.class);
|
||||
Item dropped = mock(Item.class);
|
||||
ItemStack requested = mock(ItemStack.class);
|
||||
ItemStack requestedCopy = mock(ItemStack.class);
|
||||
ItemStack leftover = mock(ItemStack.class);
|
||||
ItemStack leftoverCopy = mock(ItemStack.class);
|
||||
when(requested.clone()).thenReturn(requestedCopy);
|
||||
when(leftover.clone()).thenReturn(leftoverCopy);
|
||||
when(leftover.getAmount()).thenReturn(2);
|
||||
when(player.getUniqueId()).thenReturn(owner);
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(player.getLocation()).thenReturn(feet);
|
||||
when(inventory.getStorageContents()).thenReturn(new ItemStack[36]);
|
||||
HashMap<Integer, ItemStack> leftovers = new HashMap<>();
|
||||
leftovers.put(0, leftover);
|
||||
when(inventory.addItem(any(ItemStack.class))).thenReturn(leftovers);
|
||||
when(world.dropItem(any(Location.class), any(ItemStack.class))).thenReturn(dropped);
|
||||
|
||||
QuestClaimInventory.Delivery delivery = BukkitQuestClaimInventory.deliver(
|
||||
player, List.of(requested)
|
||||
);
|
||||
|
||||
assertEquals(2, delivery.overflowAmount());
|
||||
verify(world).dropItem(feet, leftoverCopy);
|
||||
verify(dropped).setOwner(owner);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerProtectionFailureRestoresInventoryAndRemovesPartialDrop() {
|
||||
Player player = mock(Player.class);
|
||||
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||
World world = mock(World.class);
|
||||
Location feet = mock(Location.class);
|
||||
Item dropped = mock(Item.class);
|
||||
ItemStack requested = mock(ItemStack.class);
|
||||
ItemStack requestedCopy = mock(ItemStack.class);
|
||||
ItemStack leftover = mock(ItemStack.class);
|
||||
ItemStack leftoverCopy = mock(ItemStack.class);
|
||||
ItemStack existing = mock(ItemStack.class);
|
||||
ItemStack existingCopy = mock(ItemStack.class);
|
||||
when(requested.clone()).thenReturn(requestedCopy);
|
||||
when(leftover.clone()).thenReturn(leftoverCopy);
|
||||
when(existing.clone()).thenReturn(existingCopy);
|
||||
when(existingCopy.clone()).thenReturn(existingCopy);
|
||||
ItemStack[] original = new ItemStack[] {existing};
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(player.getLocation()).thenReturn(feet);
|
||||
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(inventory.getStorageContents()).thenReturn(original);
|
||||
HashMap<Integer, ItemStack> leftovers = new HashMap<>();
|
||||
leftovers.put(0, leftover);
|
||||
when(inventory.addItem(requestedCopy)).thenReturn(leftovers);
|
||||
when(world.dropItem(any(Location.class), any(ItemStack.class))).thenReturn(dropped);
|
||||
doThrow(new IllegalStateException("owner rejected")).when(dropped).setOwner(any());
|
||||
|
||||
assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> BukkitQuestClaimInventory.deliver(player, List.of(requested))
|
||||
);
|
||||
|
||||
verify(inventory).setStorageContents(any(ItemStack[].class));
|
||||
verify(dropped).remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Base64;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
final class EscrowItemTest {
|
||||
@Test
|
||||
void capturesStyledCustomNameAsSafePlainTextWithoutChangingSerializedBytes() {
|
||||
byte[] exactBytes = new byte[] {1, 2, 3, 4};
|
||||
ItemStack original = mock(ItemStack.class);
|
||||
ItemStack snapshot = mock(ItemStack.class);
|
||||
ItemMeta metadata = mock(ItemMeta.class);
|
||||
when(original.getType()).thenReturn(Material.PAPER);
|
||||
when(original.getAmount()).thenReturn(2);
|
||||
when(original.clone()).thenReturn(snapshot);
|
||||
when(snapshot.getType()).thenReturn(Material.PAPER);
|
||||
when(snapshot.getAmount()).thenReturn(2);
|
||||
when(snapshot.serializeAsBytes()).thenReturn(exactBytes);
|
||||
when(snapshot.hasItemMeta()).thenReturn(true);
|
||||
when(snapshot.getItemMeta()).thenReturn(metadata);
|
||||
when(metadata.hasCustomName()).thenReturn(true);
|
||||
when(metadata.customName()).thenReturn(
|
||||
Component.text(" Shopping", NamedTextColor.GOLD, TextDecoration.BOLD)
|
||||
.append(Component.text("\nList ", NamedTextColor.BLUE))
|
||||
);
|
||||
|
||||
EscrowItem escrow = EscrowItem.fromItemStack(original);
|
||||
|
||||
assertEquals("Shopping List", escrow.customName());
|
||||
assertArrayEquals(exactBytes, Base64.getDecoder().decode(escrow.serializedItem()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresVanillaDisplayNameWhenNoTrueCustomNameExists() {
|
||||
ItemStack original = mock(ItemStack.class);
|
||||
ItemStack snapshot = mock(ItemStack.class);
|
||||
ItemMeta metadata = mock(ItemMeta.class);
|
||||
when(original.getType()).thenReturn(Material.DIAMOND);
|
||||
when(original.getAmount()).thenReturn(3);
|
||||
when(original.clone()).thenReturn(snapshot);
|
||||
when(snapshot.getType()).thenReturn(Material.DIAMOND);
|
||||
when(snapshot.getAmount()).thenReturn(3);
|
||||
when(snapshot.serializeAsBytes()).thenReturn(new byte[] {9});
|
||||
when(snapshot.hasItemMeta()).thenReturn(true);
|
||||
when(snapshot.getItemMeta()).thenReturn(metadata);
|
||||
when(metadata.hasCustomName()).thenReturn(false);
|
||||
|
||||
assertNull(EscrowItem.fromItemStack(original).customName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void historicalInferenceDoesNotWeakenExactEnvelopeValidation() {
|
||||
String data = Base64.getEncoder().encodeToString(new byte[] {7});
|
||||
EscrowItem escrow = new EscrowItem("PAPER", 1, data);
|
||||
ItemStack mismatched = mock(ItemStack.class);
|
||||
when(mismatched.getType()).thenReturn(Material.BOOK);
|
||||
when(mismatched.getAmount()).thenReturn(1);
|
||||
|
||||
try (MockedStatic<ItemStack> itemStacks = Mockito.mockStatic(ItemStack.class)) {
|
||||
itemStacks.when(() -> ItemStack.deserializeBytes(Mockito.any(byte[].class)))
|
||||
.thenReturn(mismatched);
|
||||
|
||||
assertEquals(escrow, escrow.inferCustomName());
|
||||
assertThrows(IllegalStateException.class, escrow::toItemStack);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import io.papermc.paper.dialog.Dialog;
|
||||
import io.papermc.paper.dialog.DialogResponseView;
|
||||
import io.papermc.paper.registry.RegistryAccess;
|
||||
import io.papermc.paper.registry.RegistryBuilderFactory;
|
||||
import io.papermc.paper.registry.data.InlinedRegistryBuilderProvider;
|
||||
import io.papermc.paper.registry.data.dialog.*;
|
||||
import io.papermc.paper.registry.data.dialog.action.*;
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
import net.kyori.adventure.audience.Audience;
|
||||
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
/** Intercepts only server API factories; invokes the actual callback installed by production UI. */
|
||||
final class NativeDialogHarness implements AutoCloseable {
|
||||
private final MockedStatic<RegistryAccess> registries = mockStatic(RegistryAccess.class);
|
||||
private final MockedStatic<DialogInstancesProvider> instances = mockStatic(DialogInstancesProvider.class);
|
||||
private final MockedStatic<InlinedRegistryBuilderProvider> inlined = mockStatic(InlinedRegistryBuilderProvider.class);
|
||||
private final Map<DialogAction, DialogActionCallback> callbacks = new IdentityHashMap<>();
|
||||
private final Map<String, DialogActionCallback> buttons = new HashMap<>();
|
||||
final List<String> messages = new ArrayList<>();
|
||||
|
||||
NativeDialogHarness() {
|
||||
RegistryAccess access = mock(RegistryAccess.class, RETURNS_DEEP_STUBS);
|
||||
registries.when(RegistryAccess::registryAccess).thenReturn(access);
|
||||
// Registry initializes legacy constants through RegistryAccess. Warm it with deep stubs,
|
||||
// then replace the dialog registry with a non-deep mock before Dialog initializes.
|
||||
access.getRegistry(io.papermc.paper.registry.RegistryKey.DIALOG);
|
||||
org.bukkit.Registry<Dialog> dialogRegistry = dialogRegistry();
|
||||
doReturn(dialogRegistry).when(access).getRegistry(io.papermc.paper.registry.RegistryKey.DIALOG);
|
||||
// Initialize Dialog's builtin constants while the registry service is intercepted.
|
||||
Dialog dialog = mock(Dialog.class);
|
||||
DialogInstancesProvider provider = mock(DialogInstancesProvider.class, RETURNS_DEEP_STUBS);
|
||||
instances.when(DialogInstancesProvider::instance).thenReturn(provider);
|
||||
when(provider.register(any(), any())).thenAnswer(call -> {
|
||||
DialogAction.CustomClickAction action = mock(DialogAction.CustomClickAction.class);
|
||||
callbacks.put(action, call.getArgument(0));
|
||||
return action;
|
||||
});
|
||||
when(provider.actionButtonBuilder(any())).thenAnswer(call -> {
|
||||
String label = PlainTextComponentSerializer.plainText().serialize(call.getArgument(0));
|
||||
ActionButton.Builder builder = mock(ActionButton.Builder.class, RETURNS_SELF);
|
||||
when(builder.action(any())).thenAnswer(action -> {
|
||||
buttons.put(label, callbacks.get(action.getArgument(0)));
|
||||
return builder;
|
||||
});
|
||||
when(builder.build()).thenReturn(mock(ActionButton.class));
|
||||
return builder;
|
||||
});
|
||||
when(provider.plainMessageDialogBody(any(), anyInt())).thenAnswer(call -> {
|
||||
messages.add(PlainTextComponentSerializer.plainText().serialize(call.getArgument(0)));
|
||||
return mock(io.papermc.paper.registry.data.dialog.body.PlainMessageDialogBody.class);
|
||||
});
|
||||
InlinedRegistryBuilderProvider inline = mock(InlinedRegistryBuilderProvider.class);
|
||||
inlined.when(InlinedRegistryBuilderProvider::instance).thenReturn(inline);
|
||||
when(inline.createDialog(any())).thenAnswer(call -> {
|
||||
Consumer<RegistryBuilderFactory<Dialog, ? extends DialogRegistryEntry.Builder>> factory = call.getArgument(0);
|
||||
RegistryBuilderFactory<Dialog, DialogRegistryEntry.Builder> builder = factory();
|
||||
factory.accept(builder);
|
||||
return dialog;
|
||||
});
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static org.bukkit.Registry<Dialog> dialogRegistry() {
|
||||
return mock(org.bukkit.Registry.class);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static RegistryBuilderFactory<Dialog, DialogRegistryEntry.Builder> factory() {
|
||||
RegistryBuilderFactory<Dialog, DialogRegistryEntry.Builder> factory = mock(RegistryBuilderFactory.class);
|
||||
DialogRegistryEntry.Builder builder = mock(DialogRegistryEntry.Builder.class, RETURNS_SELF);
|
||||
when(factory.empty()).thenReturn(builder);
|
||||
return factory;
|
||||
}
|
||||
|
||||
DialogActionCallback callback(String label) {
|
||||
DialogActionCallback callback = buttons.get(label);
|
||||
assertNotNull(callback, "No native callback registered for " + label);
|
||||
return callback;
|
||||
}
|
||||
|
||||
void click(String label, Audience player, Map<String, String> values) {
|
||||
DialogResponseView response = mock(DialogResponseView.class);
|
||||
when(response.getText(anyString())).thenAnswer(call -> values.get(call.getArgument(0)));
|
||||
callback(label).accept(response, player);
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
inlined.close();
|
||||
instances.close();
|
||||
registries.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class PhysicalBoardCreatorTest {
|
||||
private static final UUID WORLD_ID = UUID.fromString(
|
||||
"00000000-0000-0000-0000-000000000010"
|
||||
);
|
||||
private static final BoardId ANCHOR = new BoardId(WORLD_ID, 10, 64, 20);
|
||||
|
||||
@Test
|
||||
void buildsAndAtomicallyRegistersEveryVisiblePanelBlock() throws Exception {
|
||||
RecordingRepository repository = new RecordingRepository(false);
|
||||
BoardRegistry registry = new BoardRegistry(repository);
|
||||
FakeWorld world = new FakeWorld();
|
||||
PhysicalBoardCreator creator = creator(registry, world);
|
||||
|
||||
assertEquals(
|
||||
PhysicalBoardCreationResult.CREATED,
|
||||
creator.create(anchorBlock(), BoardFacing.NORTH)
|
||||
);
|
||||
|
||||
assertEquals(25, world.placed.size());
|
||||
assertEquals("AIR", world.materials.getOrDefault(ANCHOR, "AIR"));
|
||||
assertEquals(17, registry.size());
|
||||
assertEquals(17, repository.state.boards().size());
|
||||
assertEquals(1, repository.saves);
|
||||
assertTrue(repository.state.boards().stream().allMatch(
|
||||
board -> board.worldName().equals("survival")
|
||||
));
|
||||
assertTrue(PhysicalBoardPlan.create(ANCHOR, BoardFacing.NORTH)
|
||||
.interactionLocations().stream().allMatch(location -> registry.isWithin(
|
||||
location.worldId(), location.x(), location.y(), location.z(), 5.0
|
||||
)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void obstructionPreflightChangesAndRegistersNothing() throws Exception {
|
||||
RecordingRepository repository = new RecordingRepository(false);
|
||||
BoardRegistry registry = new BoardRegistry(repository);
|
||||
FakeWorld world = new FakeWorld();
|
||||
BoardId obstruction = PhysicalBoardPlan.create(ANCHOR, BoardFacing.EAST)
|
||||
.placements().stream()
|
||||
.filter(placement -> placement.signKind() == PhysicalBoardSignKind.TITLE)
|
||||
.findFirst().orElseThrow().location();
|
||||
world.materials.put(obstruction, "STONE");
|
||||
|
||||
assertEquals(
|
||||
PhysicalBoardCreationResult.OBSTRUCTED,
|
||||
creator(registry, world).create(anchorBlock(), BoardFacing.EAST)
|
||||
);
|
||||
|
||||
assertTrue(world.placed.isEmpty());
|
||||
assertEquals("STONE", world.materials.get(obstruction));
|
||||
assertEquals(0, repository.saves);
|
||||
assertEquals(0, registry.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void placementFailureRestoresEveryChangedBlockAndRegistersNothing() throws Exception {
|
||||
RecordingRepository repository = new RecordingRepository(false);
|
||||
BoardRegistry registry = new BoardRegistry(repository);
|
||||
FakeWorld world = new FakeWorld();
|
||||
world.failPlacement = 11;
|
||||
|
||||
assertThrows(IOException.class, () -> creator(registry, world).create(
|
||||
anchorBlock(), BoardFacing.SOUTH
|
||||
));
|
||||
|
||||
assertTrue(world.materials.values().stream().allMatch("AIR"::equals));
|
||||
assertEquals(0, repository.saves);
|
||||
assertEquals(0, registry.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistenceFailureRestoresWholeStructureAndPublishesNothing() throws Exception {
|
||||
RecordingRepository repository = new RecordingRepository(true);
|
||||
BoardRegistry registry = new BoardRegistry(repository);
|
||||
FakeWorld world = new FakeWorld();
|
||||
|
||||
assertThrows(IOException.class, () -> creator(registry, world).create(
|
||||
anchorBlock(), BoardFacing.WEST
|
||||
));
|
||||
|
||||
assertTrue(world.materials.values().stream().allMatch("AIR"::equals));
|
||||
assertEquals(1, repository.saves);
|
||||
assertEquals(0, registry.size());
|
||||
}
|
||||
|
||||
private static PhysicalBoardCreator creator(BoardRegistry registry, FakeWorld world) {
|
||||
return new PhysicalBoardCreator(registry, ignored -> world);
|
||||
}
|
||||
|
||||
private static Block anchorBlock() {
|
||||
World world = mock(World.class);
|
||||
when(world.getUID()).thenReturn(WORLD_ID);
|
||||
when(world.getName()).thenReturn("survival");
|
||||
Block block = mock(Block.class);
|
||||
when(block.getWorld()).thenReturn(world);
|
||||
when(block.getX()).thenReturn(ANCHOR.x());
|
||||
when(block.getY()).thenReturn(ANCHOR.y());
|
||||
when(block.getZ()).thenReturn(ANCHOR.z());
|
||||
return block;
|
||||
}
|
||||
|
||||
private static final class FakeWorld implements PhysicalBoardWorld {
|
||||
private final Map<BoardId, String> materials = new HashMap<>();
|
||||
private final Map<BoardId, String> placed = new HashMap<>();
|
||||
private int placements;
|
||||
private int failPlacement = -1;
|
||||
|
||||
@Override public boolean isEmpty(BoardId location) {
|
||||
return "AIR".equals(materials.getOrDefault(location, "AIR"));
|
||||
}
|
||||
|
||||
@Override public Object snapshot(BoardId location) {
|
||||
return materials.getOrDefault(location, "AIR");
|
||||
}
|
||||
|
||||
@Override public void place(PhysicalBoardPlan.Placement placement, BoardFacing facing) {
|
||||
if (placements++ == failPlacement) {
|
||||
throw new IllegalStateException("simulated sign configuration failure");
|
||||
}
|
||||
materials.put(placement.location(), placement.material());
|
||||
placed.put(placement.location(), placement.material());
|
||||
}
|
||||
|
||||
@Override public void restore(BoardId location, Object snapshot) {
|
||||
materials.put(location, (String) snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingRepository implements BoardRepository {
|
||||
private BoardState state = new BoardState(Set.of());
|
||||
private final boolean failSave;
|
||||
private int saves;
|
||||
|
||||
private RecordingRepository(boolean failSave) {
|
||||
this.failSave = failSave;
|
||||
}
|
||||
|
||||
@Override public BoardState load() {
|
||||
return state;
|
||||
}
|
||||
|
||||
@Override public void save(BoardState state) throws IOException {
|
||||
saves++;
|
||||
if (failSave) {
|
||||
throw new IOException("disk full");
|
||||
}
|
||||
this.state = state;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class PhysicalBoardPlanTest {
|
||||
@Test
|
||||
void createsFiveWideFourTallOakBoardAboveGroundAnchor() {
|
||||
BoardId anchor = new BoardId(UUID.randomUUID(), 0, 64, 0);
|
||||
|
||||
PhysicalBoardPlan plan = PhysicalBoardPlan.create(anchor, BoardFacing.NORTH);
|
||||
|
||||
assertEquals(8, plan.placements().stream()
|
||||
.filter(block -> block.material().equals("OAK_LOG")).count());
|
||||
assertEquals(12, plan.placements().stream()
|
||||
.filter(block -> block.material().equals("OAK_PLANKS")).count());
|
||||
assertEquals(5, plan.placements().stream()
|
||||
.filter(block -> block.material().equals("OAK_WALL_SIGN")).count());
|
||||
assertEquals(17, plan.interactionLocations().size());
|
||||
assertFalse(plan.placements().stream().anyMatch(block -> block.location().equals(anchor)));
|
||||
assertEquals(-2, plan.placements().stream().mapToInt(block -> block.location().x()).min().orElseThrow());
|
||||
assertEquals(2, plan.placements().stream().mapToInt(block -> block.location().x()).max().orElseThrow());
|
||||
assertEquals(65, plan.placements().stream().mapToInt(block -> block.location().y()).min().orElseThrow());
|
||||
assertEquals(68, plan.placements().stream().mapToInt(block -> block.location().y()).max().orElseThrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rotatesWidthAndFrontMountedSignsForEveryFacing() {
|
||||
BoardId anchor = new BoardId(UUID.randomUUID(), 10, 64, 20);
|
||||
|
||||
for (BoardFacing facing : BoardFacing.values()) {
|
||||
PhysicalBoardPlan plan = PhysicalBoardPlan.create(anchor, facing);
|
||||
assertEquals(25, plan.placements().size());
|
||||
assertEquals(25, new HashSet<>(plan.placements().stream()
|
||||
.map(PhysicalBoardPlan.Placement::location).toList()).size());
|
||||
|
||||
var backing = plan.placements().stream().filter(
|
||||
placement -> !placement.sign()
|
||||
).toList();
|
||||
assertEquals(20, backing.size());
|
||||
assertTrue(backing.stream().allMatch(placement ->
|
||||
forward(anchor, placement.location(), facing) == 0
|
||||
&& Math.abs(lateral(anchor, placement.location(), facing)) <= 2
|
||||
&& placement.location().y() >= anchor.y() + 1
|
||||
&& placement.location().y() <= anchor.y() + 4
|
||||
));
|
||||
|
||||
var signs = plan.placements().stream().filter(
|
||||
PhysicalBoardPlan.Placement::sign
|
||||
).toList();
|
||||
assertEquals(5, signs.size());
|
||||
assertTrue(signs.stream().allMatch(sign ->
|
||||
forward(anchor, sign.location(), facing) == 1
|
||||
&& Math.abs(lateral(anchor, sign.location(), facing)) <= 1
|
||||
&& sign.location().y() >= anchor.y() + 2
|
||||
&& sign.location().y() <= anchor.y() + 4
|
||||
));
|
||||
assertEquals(1, signs.stream()
|
||||
.filter(sign -> sign.signKind() == PhysicalBoardSignKind.TITLE).count());
|
||||
assertEquals(1, signs.stream()
|
||||
.filter(sign -> sign.signKind() == PhysicalBoardSignKind.INSTRUCTION).count());
|
||||
assertEquals(3, signs.stream()
|
||||
.filter(sign -> sign.signKind() == PhysicalBoardSignKind.DECORATIVE).count());
|
||||
assertEquals(17, plan.interactionLocations().size());
|
||||
assertTrue(plan.interactionLocations().containsAll(signs.stream()
|
||||
.map(PhysicalBoardPlan.Placement::location).toList()));
|
||||
assertTrue(backing.stream()
|
||||
.filter(placement -> placement.material().equals("OAK_PLANKS"))
|
||||
.allMatch(placement -> plan.interactionLocations().contains(
|
||||
placement.location()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
private static int forward(BoardId anchor, BoardId location, BoardFacing facing) {
|
||||
return (location.x() - anchor.x()) * facing.x()
|
||||
+ (location.z() - anchor.z()) * facing.z();
|
||||
}
|
||||
|
||||
private static int lateral(BoardId anchor, BoardId location, BoardFacing facing) {
|
||||
return (location.x() - anchor.x()) * facing.rightX()
|
||||
+ (location.z() - anchor.z()) * facing.rightZ();
|
||||
}
|
||||
|
||||
@Test
|
||||
void boardFrontFacesOppositeTheAdministratorsLookDirection() {
|
||||
assertEquals(BoardFacing.SOUTH, BoardFacing.towardPlayer(org.bukkit.block.BlockFace.NORTH));
|
||||
assertEquals(BoardFacing.WEST, BoardFacing.towardPlayer(org.bukkit.block.BlockFace.EAST));
|
||||
assertEquals(BoardFacing.NORTH, BoardFacing.towardPlayer(org.bukkit.block.BlockFace.SOUTH));
|
||||
assertEquals(BoardFacing.EAST, BoardFacing.towardPlayer(org.bukkit.block.BlockFace.WEST));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class PhysicalBoardUpgraderTest {
|
||||
private static final UUID WORLD_ID = UUID.fromString(
|
||||
"00000000-0000-0000-0000-000000000011"
|
||||
);
|
||||
private static final BoardId ANCHOR = new BoardId(WORLD_ID, 10, 64, 20);
|
||||
private static final BoardFacing FACING = BoardFacing.NORTH;
|
||||
|
||||
@Test
|
||||
void upgradesOnlyAnExactRegisteredLegacyStructure() throws Exception {
|
||||
RecordingRepository repository = new RecordingRepository(legacyRegistrations(), false);
|
||||
BoardRegistry registry = new BoardRegistry(repository);
|
||||
FakeWorld world = legacyWorld();
|
||||
|
||||
assertEquals(1, upgrader(registry, world).upgrade());
|
||||
|
||||
assertEquals(17, registry.size());
|
||||
assertEquals(1, repository.saves);
|
||||
assertEquals(Set.of(PhysicalBoardSignKind.TITLE, PhysicalBoardSignKind.INSTRUCTION),
|
||||
world.placed.values().stream().map(PhysicalBoardPlan.Placement::signKind)
|
||||
.collect(Collectors.toSet()));
|
||||
assertEquals(2, world.placed.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshesBothReadableSignsOnAnExactRegisteredFullBoard() throws Exception {
|
||||
RecordingRepository repository = new RecordingRepository(fullRegistrations(), false);
|
||||
BoardRegistry registry = new BoardRegistry(repository);
|
||||
FakeWorld world = fullWorld(false);
|
||||
|
||||
assertEquals(1, upgrader(registry, world).upgrade());
|
||||
|
||||
assertEquals(17, registry.size());
|
||||
assertEquals(0, repository.saves);
|
||||
assertEquals(Set.of(PhysicalBoardSignKind.TITLE, PhysicalBoardSignKind.INSTRUCTION),
|
||||
world.placed.values().stream().map(PhysicalBoardPlan.Placement::signKind)
|
||||
.collect(Collectors.toSet()));
|
||||
assertTrue(newSigns().stream().allMatch(sign -> world.glowing.get(sign.location())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotReportOrRefreshAnAlreadyGlowingFullBoard() throws Exception {
|
||||
RecordingRepository repository = new RecordingRepository(fullRegistrations(), false);
|
||||
FakeWorld world = fullWorld(true);
|
||||
|
||||
assertEquals(0, upgrader(new BoardRegistry(repository), world).upgrade());
|
||||
|
||||
assertTrue(world.placed.isEmpty());
|
||||
assertEquals(0, repository.saves);
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotAlterCustomBoardsOrStructuresThatDoNotExactlyMatch() throws Exception {
|
||||
RegisteredBoard custom = new RegisteredBoard(
|
||||
new BoardId(WORLD_ID, 100, 70, 100), "survival"
|
||||
);
|
||||
RecordingRepository customRepository = new RecordingRepository(Set.of(custom), false);
|
||||
FakeWorld customWorld = new FakeWorld();
|
||||
assertEquals(0, upgrader(new BoardRegistry(customRepository), customWorld).upgrade());
|
||||
assertTrue(customWorld.placed.isEmpty());
|
||||
|
||||
RecordingRepository changedRepository = new RecordingRepository(
|
||||
legacyRegistrations(), false
|
||||
);
|
||||
FakeWorld changedWorld = legacyWorld();
|
||||
PhysicalBoardPlan.Placement log = plan().placements().stream()
|
||||
.filter(placement -> "OAK_LOG".equals(placement.material())).findFirst().orElseThrow();
|
||||
changedWorld.existing.put(log.location(), new PhysicalBoardPlan.Placement(
|
||||
log.location(), "STONE"
|
||||
));
|
||||
assertEquals(0, upgrader(new BoardRegistry(changedRepository), changedWorld).upgrade());
|
||||
assertTrue(changedWorld.placed.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotRefreshAFullBoardWithCustomSignText() throws Exception {
|
||||
RecordingRepository repository = new RecordingRepository(fullRegistrations(), false);
|
||||
FakeWorld world = fullWorld(false);
|
||||
PhysicalBoardPlan.Placement title = newSigns().stream()
|
||||
.filter(sign -> sign.signKind() == PhysicalBoardSignKind.TITLE)
|
||||
.findFirst().orElseThrow();
|
||||
world.existing.put(title.location(), new PhysicalBoardPlan.Placement(
|
||||
title.location(), title.material(), PhysicalBoardSignKind.INSTRUCTION
|
||||
));
|
||||
|
||||
assertEquals(0, upgrader(new BoardRegistry(repository), world).upgrade());
|
||||
|
||||
assertTrue(world.placed.isEmpty());
|
||||
assertTrue(newSigns().stream().noneMatch(sign -> world.glowing.get(sign.location())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotOverwriteAnOccupiedNewSignCell() throws Exception {
|
||||
RecordingRepository repository = new RecordingRepository(legacyRegistrations(), false);
|
||||
BoardRegistry registry = new BoardRegistry(repository);
|
||||
FakeWorld world = legacyWorld();
|
||||
BoardId title = newSigns().stream()
|
||||
.filter(sign -> sign.signKind() == PhysicalBoardSignKind.TITLE)
|
||||
.findFirst().orElseThrow().location();
|
||||
world.existing.put(title, new PhysicalBoardPlan.Placement(title, "STONE"));
|
||||
|
||||
assertEquals(0, upgrader(registry, world).upgrade());
|
||||
|
||||
assertTrue(world.placed.isEmpty());
|
||||
assertEquals(15, registry.size());
|
||||
assertEquals(0, repository.saves);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rollsBackBothCellsWhenSignConstructionFails() throws Exception {
|
||||
RecordingRepository repository = new RecordingRepository(legacyRegistrations(), false);
|
||||
BoardRegistry registry = new BoardRegistry(repository);
|
||||
FakeWorld world = legacyWorld();
|
||||
world.failPlacement = 1;
|
||||
|
||||
assertThrows(IOException.class, () -> upgrader(registry, world).upgrade());
|
||||
|
||||
assertTrue(newSigns().stream().allMatch(sign -> !world.existing.containsKey(sign.location())));
|
||||
assertEquals(15, registry.size());
|
||||
assertEquals(0, repository.saves);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rollsBackBothReadableSignsWhenFullBoardRefreshFails() throws Exception {
|
||||
RecordingRepository repository = new RecordingRepository(fullRegistrations(), false);
|
||||
FakeWorld world = fullWorld(false);
|
||||
world.failPlacement = 1;
|
||||
|
||||
assertThrows(IOException.class,
|
||||
() -> upgrader(new BoardRegistry(repository), world).upgrade());
|
||||
|
||||
assertTrue(newSigns().stream().noneMatch(sign -> world.glowing.get(sign.location())));
|
||||
assertEquals(0, repository.saves);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rollsBackBothCellsWhenAtomicPersistenceFails() throws Exception {
|
||||
RecordingRepository repository = new RecordingRepository(legacyRegistrations(), true);
|
||||
BoardRegistry registry = new BoardRegistry(repository);
|
||||
FakeWorld world = legacyWorld();
|
||||
|
||||
assertThrows(IOException.class, () -> upgrader(registry, world).upgrade());
|
||||
|
||||
assertTrue(newSigns().stream().allMatch(sign -> !world.existing.containsKey(sign.location())));
|
||||
assertEquals(15, registry.size());
|
||||
assertEquals(1, repository.saves);
|
||||
}
|
||||
|
||||
private static PhysicalBoardUpgrader upgrader(BoardRegistry registry, FakeWorld world) {
|
||||
return new PhysicalBoardUpgrader(
|
||||
registry, worldId -> WORLD_ID.equals(worldId) ? world : null
|
||||
);
|
||||
}
|
||||
|
||||
private static PhysicalBoardPlan plan() {
|
||||
return PhysicalBoardPlan.create(ANCHOR, FACING);
|
||||
}
|
||||
|
||||
private static Set<RegisteredBoard> legacyRegistrations() {
|
||||
return plan().placements().stream().filter(placement ->
|
||||
"OAK_PLANKS".equals(placement.material())
|
||||
|| placement.signKind() == PhysicalBoardSignKind.DECORATIVE
|
||||
).map(placement -> new RegisteredBoard(placement.location(), "survival"))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private static Set<RegisteredBoard> fullRegistrations() {
|
||||
return plan().interactionLocations().stream()
|
||||
.map(location -> new RegisteredBoard(location, "survival"))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private static FakeWorld legacyWorld() {
|
||||
FakeWorld world = new FakeWorld();
|
||||
plan().placements().stream().filter(placement ->
|
||||
placement.signKind() != PhysicalBoardSignKind.TITLE
|
||||
&& placement.signKind() != PhysicalBoardSignKind.INSTRUCTION
|
||||
).forEach(placement -> {
|
||||
world.existing.put(placement.location(), placement);
|
||||
world.facings.put(placement.location(), FACING);
|
||||
});
|
||||
return world;
|
||||
}
|
||||
|
||||
private static FakeWorld fullWorld(boolean readableGlowing) {
|
||||
FakeWorld world = new FakeWorld();
|
||||
plan().placements().forEach(placement -> {
|
||||
world.existing.put(placement.location(), placement);
|
||||
if (placement.sign()) {
|
||||
world.facings.put(placement.location(), FACING);
|
||||
world.glowing.put(placement.location(),
|
||||
placement.signKind() != PhysicalBoardSignKind.DECORATIVE && readableGlowing);
|
||||
}
|
||||
});
|
||||
return world;
|
||||
}
|
||||
|
||||
private static java.util.List<PhysicalBoardPlan.Placement> newSigns() {
|
||||
return plan().placements().stream().filter(placement ->
|
||||
placement.signKind() == PhysicalBoardSignKind.TITLE
|
||||
|| placement.signKind() == PhysicalBoardSignKind.INSTRUCTION
|
||||
).toList();
|
||||
}
|
||||
|
||||
private static final class FakeWorld implements PhysicalBoardWorld {
|
||||
private record Snapshot(
|
||||
PhysicalBoardPlan.Placement placement, BoardFacing facing, Boolean glowing
|
||||
) { }
|
||||
|
||||
private final Map<BoardId, PhysicalBoardPlan.Placement> existing = new HashMap<>();
|
||||
private final Map<BoardId, BoardFacing> facings = new HashMap<>();
|
||||
private final Map<BoardId, Boolean> glowing = new HashMap<>();
|
||||
private final Map<BoardId, PhysicalBoardPlan.Placement> placed = new HashMap<>();
|
||||
private int placements;
|
||||
private int failPlacement = -1;
|
||||
|
||||
@Override public boolean isEmpty(BoardId location) {
|
||||
return !existing.containsKey(location);
|
||||
}
|
||||
|
||||
@Override public Object snapshot(BoardId location) {
|
||||
return new Snapshot(
|
||||
existing.get(location), facings.get(location), glowing.get(location)
|
||||
);
|
||||
}
|
||||
|
||||
@Override public void place(PhysicalBoardPlan.Placement placement, BoardFacing facing) {
|
||||
if (placements++ == failPlacement) {
|
||||
throw new IllegalStateException("simulated sign build failure");
|
||||
}
|
||||
existing.put(placement.location(), placement);
|
||||
facings.put(placement.location(), facing);
|
||||
glowing.put(placement.location(),
|
||||
placement.signKind() != PhysicalBoardSignKind.DECORATIVE);
|
||||
placed.put(placement.location(), placement);
|
||||
}
|
||||
|
||||
@Override public boolean matches(
|
||||
PhysicalBoardPlan.Placement placement, BoardFacing facing
|
||||
) {
|
||||
return placement.equals(existing.get(placement.location()))
|
||||
&& (!placement.sign() || facing == facings.get(placement.location()));
|
||||
}
|
||||
|
||||
@Override public boolean matches(
|
||||
PhysicalBoardPlan.Placement placement, BoardFacing facing, boolean glowingText
|
||||
) {
|
||||
return matches(placement, facing)
|
||||
&& glowingText == glowing.getOrDefault(placement.location(), false);
|
||||
}
|
||||
|
||||
@Override public void restore(BoardId location, Object snapshot) {
|
||||
Snapshot captured = (Snapshot) snapshot;
|
||||
restore(existing, location, captured.placement());
|
||||
restore(facings, location, captured.facing());
|
||||
restore(glowing, location, captured.glowing());
|
||||
}
|
||||
|
||||
private static <T> void restore(Map<BoardId, T> values, BoardId location, T value) {
|
||||
if (value == null) {
|
||||
values.remove(location);
|
||||
} else {
|
||||
values.put(location, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingRepository implements BoardRepository {
|
||||
private BoardState state;
|
||||
private final boolean failSave;
|
||||
private int saves;
|
||||
|
||||
private RecordingRepository(Set<RegisteredBoard> boards, boolean failSave) {
|
||||
state = new BoardState(boards);
|
||||
this.failSave = failSave;
|
||||
}
|
||||
|
||||
@Override public BoardState load() {
|
||||
return state;
|
||||
}
|
||||
|
||||
@Override public void save(BoardState state) throws IOException {
|
||||
saves++;
|
||||
if (failSave) {
|
||||
throw new IOException("disk full");
|
||||
}
|
||||
this.state = state;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
final class PlayerCommandSettingsTest {
|
||||
@TempDir Path temporaryDirectory;
|
||||
|
||||
@Test
|
||||
void defaultsDisabledAndPersistsEnabledState() throws Exception {
|
||||
Path path = temporaryDirectory.resolve("settings.yml");
|
||||
PlayerCommandSettings settings = new PlayerCommandSettings(
|
||||
new YamlPlayerCommandSettingsRepository(path)
|
||||
);
|
||||
|
||||
assertFalse(settings.enabled());
|
||||
settings.setEnabled(true);
|
||||
|
||||
PlayerCommandSettings reloaded = new PlayerCommandSettings(
|
||||
new YamlPlayerCommandSettingsRepository(path)
|
||||
);
|
||||
assertTrue(reloaded.enabled());
|
||||
|
||||
reloaded.setEnabled(false);
|
||||
|
||||
assertFalse(new PlayerCommandSettings(
|
||||
new YamlPlayerCommandSettingsRepository(path)
|
||||
).enabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistenceFailureLeavesPriorState() throws Exception {
|
||||
PlayerCommandSettings settings = new PlayerCommandSettings(
|
||||
new PlayerCommandSettingsRepository() {
|
||||
@Override public boolean loadEnabled() { return false; }
|
||||
@Override public void saveEnabled(boolean enabled) throws IOException {
|
||||
throw new IOException("disk full");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
assertThrows(IOException.class, () -> settings.setEnabled(true));
|
||||
|
||||
assertFalse(settings.enabled());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class PluginCommandWiringTest {
|
||||
@Test
|
||||
void startupPhysicalBoardUpgradeDelegatesToLoadedWorlds() throws Exception {
|
||||
UUID worldId = UUID.randomUUID();
|
||||
PhysicalBoardPlan plan = PhysicalBoardPlan.create(
|
||||
new BoardId(worldId, 1, 64, 1), BoardFacing.NORTH
|
||||
);
|
||||
Set<RegisteredBoard> legacy = plan.placements().stream()
|
||||
.filter(placement -> "OAK_PLANKS".equals(placement.material())
|
||||
|| placement.signKind() == PhysicalBoardSignKind.DECORATIVE)
|
||||
.map(placement -> new RegisteredBoard(placement.location(), "survival"))
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
BoardRegistry registry = new BoardRegistry(new BoardRepository() {
|
||||
@Override public BoardState load() { return new BoardState(legacy); }
|
||||
@Override public void save(BoardState state) { }
|
||||
});
|
||||
AtomicInteger worldLookups = new AtomicInteger();
|
||||
|
||||
int upgraded = SpigotQuestBoardPlugin.upgradePhysicalBoards(registry, id -> {
|
||||
assertEquals(worldId, id);
|
||||
worldLookups.incrementAndGet();
|
||||
return null;
|
||||
});
|
||||
|
||||
assertEquals(0, upgraded);
|
||||
assertTrue(worldLookups.get() > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void playerCommandUsesBoardRegistryForNearbyCreation() throws Exception {
|
||||
UUID worldId = UUID.randomUUID();
|
||||
BoardId boardId = new BoardId(worldId, 10, 64, 20);
|
||||
BoardRegistry boards = new BoardRegistry(new BoardRepository() {
|
||||
@Override public BoardState load() {
|
||||
return new BoardState(Set.of(new RegisteredBoard(boardId, "survival")));
|
||||
}
|
||||
@Override public void save(BoardState state) { }
|
||||
});
|
||||
PlayerCommandSettings settings = new PlayerCommandSettings(
|
||||
new PlayerCommandSettingsRepository() {
|
||||
@Override public boolean loadEnabled() { return false; }
|
||||
@Override public void saveEnabled(boolean enabled) { }
|
||||
}
|
||||
);
|
||||
QuestCreationGateway creator = mock(QuestCreationGateway.class);
|
||||
Quest created = mock(Quest.class);
|
||||
when(created.id()).thenReturn(UUID.randomUUID());
|
||||
when(creator.create(
|
||||
org.mockito.ArgumentMatchers.any(),
|
||||
org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.anyInt(),
|
||||
org.mockito.ArgumentMatchers.any()
|
||||
)).thenReturn(created);
|
||||
SpigotQuestBoardPlugin.CommandHandlers handlers =
|
||||
SpigotQuestBoardPlugin.commandHandlers(
|
||||
boards, settings, creator, mock(QuestBrowser.class), null, null, null,
|
||||
Clock.systemUTC()
|
||||
);
|
||||
World world = mock(World.class);
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
Player player = mock(Player.class);
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(player.getLocation()).thenReturn(new Location(world, 13, 68, 20));
|
||||
|
||||
assertTrue(handlers.quests().onCommand(
|
||||
player, mock(Command.class), "quests", new String[] {"create", "stone", "1"}
|
||||
));
|
||||
|
||||
verify(creator).create(
|
||||
org.mockito.ArgumentMatchers.eq(player),
|
||||
org.mockito.ArgumentMatchers.eq("stone"),
|
||||
org.mockito.ArgumentMatchers.eq(1),
|
||||
org.mockito.ArgumentMatchers.any()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminAndPlayerCommandsShareThePersistedSetting() throws Exception {
|
||||
PlayerCommandSettings settings = new PlayerCommandSettings(
|
||||
new PlayerCommandSettingsRepository() {
|
||||
private boolean enabled;
|
||||
@Override public boolean loadEnabled() { return enabled; }
|
||||
@Override public void saveEnabled(boolean enabled) { this.enabled = enabled; }
|
||||
}
|
||||
);
|
||||
BoardRepository boards = new BoardRepository() {
|
||||
@Override public BoardState load() { return new BoardState(Set.of()); }
|
||||
@Override public void save(BoardState state) { }
|
||||
};
|
||||
QuestBrowser browser = mock(QuestBrowser.class);
|
||||
when(browser.activeQuests(org.mockito.ArgumentMatchers.any())).thenReturn(List.of());
|
||||
SpigotQuestBoardPlugin.CommandHandlers handlers =
|
||||
SpigotQuestBoardPlugin.commandHandlers(
|
||||
new BoardRegistry(boards), settings, mock(QuestCreationGateway.class), browser,
|
||||
null, null, null, Clock.systemUTC()
|
||||
);
|
||||
Player player = mock(Player.class);
|
||||
CommandSender admin = mock(CommandSender.class);
|
||||
when(admin.hasPermission("spigotquestboard.admin")).thenReturn(true);
|
||||
Command command = mock(Command.class);
|
||||
|
||||
assertTrue(handlers.quests().onCommand(
|
||||
player, command, "quests", new String[] {"list"}
|
||||
));
|
||||
verify(player).sendMessage(
|
||||
"Player quest commands are disabled. Use a physical quest board instead."
|
||||
);
|
||||
|
||||
handlers.admin().onCommand(
|
||||
admin, command, "questadmin", new String[] {"commands", "enable"}
|
||||
);
|
||||
handlers.quests().onCommand(player, command, "quests", new String[] {"list"});
|
||||
|
||||
verify(browser).activeQuests(org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.jar.JarFile;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
class PurpurDistributionTest {
|
||||
@Test
|
||||
void packagesNewNameWithoutChangingRuntimeIdentityOrBundlingTheApi() throws Exception {
|
||||
Path artifact = Path.of(System.getProperty("distribution.jar"));
|
||||
String version = System.getProperty("distribution.version");
|
||||
assertEquals("purpur-quest-board-" + version + ".jar", artifact.getFileName().toString());
|
||||
try (var jar = new JarFile(artifact.toFile())) {
|
||||
Map<?, ?> metadata = new Yaml().load(jar.getInputStream(jar.getJarEntry("plugin.yml")));
|
||||
assertEquals(version, metadata.get("version"));
|
||||
assertEquals("SpigotQuestBoard", metadata.get("name"), "Retain the data directory and persistent namespace");
|
||||
assertEquals("games.dmg.spigotquestboard.SpigotQuestBoardPlugin", metadata.get("main"));
|
||||
assertTrue(((Map<?, ?>) metadata.get("permissions")).containsKey("spigotquestboard.admin"));
|
||||
var description = new org.bukkit.plugin.PluginDescriptionFile(jar.getInputStream(jar.getJarEntry("plugin.yml")));
|
||||
assertEquals("spigotquestboard", description.namespace());
|
||||
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/")
|
||||
|| entry.getName().startsWith("org/purpurmc/") || entry.getName().endsWith("Test.class")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void pluginBytecodeTargetsJava25() throws Exception {
|
||||
try (var jar = new JarFile(System.getProperty("distribution.jar"));
|
||||
var bytecode = new DataInputStream(jar.getInputStream(
|
||||
jar.getJarEntry("games/dmg/spigotquestboard/SpigotQuestBoardPlugin.class")))) {
|
||||
assertEquals(0xCAFEBABE, bytecode.readInt());
|
||||
bytecode.readUnsignedShort();
|
||||
assertEquals(69, bytecode.readUnsignedShort());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception {
|
||||
String build = Files.readString(Path.of("build.gradle.kts"));
|
||||
assertTrue(build.contains("purpur-api:26.2.build.2618-stable"));
|
||||
assertFalse(build.contains("org.spigotmc:spigot-api"));
|
||||
for (String workflow : new String[] {"ci.yml", "release.yml"}) {
|
||||
String content = Files.readString(Path.of(".gitea/workflows", workflow));
|
||||
assertTrue(content.contains("java-version: 25"));
|
||||
assertTrue(content.contains("purpur-quest-board-"), workflow);
|
||||
assertFalse(content.contains("spigot-quest-board-"), workflow);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
@@ -7,9 +8,13 @@ import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandSender;
|
||||
@@ -20,7 +25,10 @@ final class QuestAdminCommandTest {
|
||||
@Test
|
||||
void unauthorizedSenderCannotCreateBoard() throws Exception {
|
||||
MemoryBoardRepository repository = new MemoryBoardRepository();
|
||||
QuestAdminCommand executor = new QuestAdminCommand(new BoardRegistry(repository));
|
||||
QuestAdminCommand executor = new QuestAdminCommand(
|
||||
new BoardRegistry(repository),
|
||||
new PlayerCommandSettings(new MemoryPlayerCommandSettingsRepository())
|
||||
);
|
||||
Player player = mock(Player.class);
|
||||
when(player.hasPermission("spigotquestboard.admin")).thenReturn(false);
|
||||
|
||||
@@ -30,10 +38,114 @@ final class QuestAdminCommandTest {
|
||||
assertTrue(repository.state.boards().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void authorizedSenderCanEnableAndDisablePlayerCommands() throws Exception {
|
||||
MemoryPlayerCommandSettingsRepository settingsRepository =
|
||||
new MemoryPlayerCommandSettingsRepository();
|
||||
PlayerCommandSettings settings = new PlayerCommandSettings(settingsRepository);
|
||||
QuestAdminCommand executor = new QuestAdminCommand(
|
||||
new BoardRegistry(new MemoryBoardRepository()), settings
|
||||
);
|
||||
CommandSender sender = mock(CommandSender.class);
|
||||
when(sender.hasPermission("spigotquestboard.admin")).thenReturn(true);
|
||||
|
||||
assertTrue(executor.onCommand(
|
||||
sender, mock(Command.class), "questadmin", new String[] {"commands", "enable"}
|
||||
));
|
||||
assertTrue(settings.enabled());
|
||||
assertTrue(executor.onCommand(
|
||||
sender, mock(Command.class), "questadmin", new String[] {"commands", "disable"}
|
||||
));
|
||||
|
||||
assertFalse(settings.enabled());
|
||||
verify(sender).sendMessage("Player quest commands enabled.");
|
||||
verify(sender).sendMessage("Player quest commands disabled.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unauthorizedSenderCannotChangePlayerCommands() throws Exception {
|
||||
MemoryPlayerCommandSettingsRepository repository =
|
||||
new MemoryPlayerCommandSettingsRepository();
|
||||
PlayerCommandSettings settings = new PlayerCommandSettings(repository);
|
||||
QuestAdminCommand executor = new QuestAdminCommand(
|
||||
new BoardRegistry(new MemoryBoardRepository()), settings
|
||||
);
|
||||
CommandSender sender = mock(CommandSender.class);
|
||||
when(sender.hasPermission("spigotquestboard.admin")).thenReturn(false);
|
||||
|
||||
executor.onCommand(
|
||||
sender, mock(Command.class), "questadmin", new String[] {"commands", "enable"}
|
||||
);
|
||||
|
||||
assertFalse(settings.enabled());
|
||||
assertEquals(0, repository.saves);
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingsPersistenceFailureIsReportedAndLeavesPriorState() throws Exception {
|
||||
PlayerCommandSettings settings = new PlayerCommandSettings(
|
||||
new PlayerCommandSettingsRepository() {
|
||||
@Override public boolean loadEnabled() { return false; }
|
||||
@Override public void saveEnabled(boolean enabled) throws IOException {
|
||||
throw new IOException("disk full");
|
||||
}
|
||||
}
|
||||
);
|
||||
QuestAdminCommand executor = new QuestAdminCommand(
|
||||
new BoardRegistry(new MemoryBoardRepository()), settings
|
||||
);
|
||||
CommandSender sender = mock(CommandSender.class);
|
||||
when(sender.hasPermission("spigotquestboard.admin")).thenReturn(true);
|
||||
|
||||
executor.onCommand(
|
||||
sender, mock(Command.class), "questadmin", new String[] {"commands", "enable"}
|
||||
);
|
||||
|
||||
assertFalse(settings.enabled());
|
||||
verify(sender).sendMessage(
|
||||
"Player command setting could not be saved. Player quest commands remain disabled."
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void autocompleteIsPermissionAwareAndContextual() throws Exception {
|
||||
QuestAdminCommand executor = new QuestAdminCommand(
|
||||
new BoardRegistry(new MemoryBoardRepository()),
|
||||
new PlayerCommandSettings(new MemoryPlayerCommandSettingsRepository())
|
||||
);
|
||||
CommandSender authorized = mock(CommandSender.class);
|
||||
when(authorized.hasPermission("spigotquestboard.admin")).thenReturn(true);
|
||||
CommandSender unauthorized = mock(CommandSender.class);
|
||||
when(unauthorized.hasPermission("spigotquestboard.admin")).thenReturn(false);
|
||||
Command command = mock(Command.class);
|
||||
|
||||
assertEquals(List.of("commands"), executor.onTabComplete(
|
||||
authorized, command, "questadmin", new String[] {"com"}
|
||||
));
|
||||
assertEquals(List.of("enable"), executor.onTabComplete(
|
||||
authorized, command, "questadmin", new String[] {"commands", "en"}
|
||||
));
|
||||
assertEquals(List.of("disable"), executor.onTabComplete(
|
||||
authorized, command, "questadmin", new String[] {"commands", "di"}
|
||||
));
|
||||
assertEquals(List.of("physical"), executor.onTabComplete(
|
||||
authorized, command, "questadmin", new String[] {"createboard", ""}
|
||||
));
|
||||
assertEquals(List.of("physical"), executor.onTabComplete(
|
||||
authorized, command, "questadmin", new String[] {"createboard", "phy"}
|
||||
));
|
||||
assertTrue(executor.onTabComplete(
|
||||
unauthorized, command, "questadmin", new String[] {""}
|
||||
).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registersTargetedPhysicalBlock() throws Exception {
|
||||
MemoryBoardRepository repository = new MemoryBoardRepository();
|
||||
QuestAdminCommand executor = new QuestAdminCommand(new BoardRegistry(repository));
|
||||
QuestAdminCommand executor = new QuestAdminCommand(
|
||||
new BoardRegistry(repository),
|
||||
new PlayerCommandSettings(new MemoryPlayerCommandSettingsRepository())
|
||||
);
|
||||
Player player = mock(Player.class);
|
||||
Block block = mock(Block.class);
|
||||
World world = mock(World.class);
|
||||
@@ -54,10 +166,102 @@ final class QuestAdminCommandTest {
|
||||
)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void physicalArgumentBuildsAndRegistersAllInteractionLocations() throws Exception {
|
||||
MemoryBoardRepository repository = new MemoryBoardRepository();
|
||||
BoardRegistry registry = new BoardRegistry(repository);
|
||||
AtomicInteger placements = new AtomicInteger();
|
||||
PhysicalBoardWorld worldAdapter = new PhysicalBoardWorld() {
|
||||
@Override public boolean isEmpty(BoardId location) { return true; }
|
||||
@Override public Object snapshot(BoardId location) { return "AIR"; }
|
||||
@Override public void place(
|
||||
PhysicalBoardPlan.Placement placement, BoardFacing facing
|
||||
) {
|
||||
assertEquals(BoardFacing.SOUTH, facing);
|
||||
placements.incrementAndGet();
|
||||
}
|
||||
@Override public void restore(BoardId location, Object snapshot) { }
|
||||
};
|
||||
QuestAdminCommand executor = new QuestAdminCommand(
|
||||
registry,
|
||||
new PlayerCommandSettings(new MemoryPlayerCommandSettingsRepository()),
|
||||
new PhysicalBoardCreator(registry, ignored -> worldAdapter)
|
||||
);
|
||||
Player player = mock(Player.class);
|
||||
Block anchor = mock(Block.class);
|
||||
World world = mock(World.class);
|
||||
UUID worldId = UUID.randomUUID();
|
||||
when(player.hasPermission("spigotquestboard.admin")).thenReturn(true);
|
||||
when(player.getTargetBlockExact(5)).thenReturn(anchor);
|
||||
when(player.getFacing()).thenReturn(BlockFace.NORTH);
|
||||
when(anchor.getWorld()).thenReturn(world);
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
when(world.getName()).thenReturn("world");
|
||||
when(anchor.getX()).thenReturn(3);
|
||||
when(anchor.getY()).thenReturn(70);
|
||||
when(anchor.getZ()).thenReturn(4);
|
||||
|
||||
assertTrue(executor.onCommand(
|
||||
player, mock(Command.class), "questadmin",
|
||||
new String[] {"createboard", "physical"}
|
||||
));
|
||||
|
||||
assertEquals(25, placements.get());
|
||||
assertEquals(17, repository.state.boards().size());
|
||||
verify(player).sendMessage("Physical quest board created.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void physicalBoardObstructionIsReportedWithoutRegistration() throws Exception {
|
||||
MemoryBoardRepository repository = new MemoryBoardRepository();
|
||||
BoardRegistry registry = new BoardRegistry(repository);
|
||||
PhysicalBoardWorld obstructedWorld = new PhysicalBoardWorld() {
|
||||
@Override public boolean isEmpty(BoardId location) { return false; }
|
||||
@Override public Object snapshot(BoardId location) {
|
||||
throw new AssertionError("obstructed generation must not snapshot");
|
||||
}
|
||||
@Override public void place(
|
||||
PhysicalBoardPlan.Placement placement, BoardFacing facing
|
||||
) {
|
||||
throw new AssertionError("obstructed generation must not place");
|
||||
}
|
||||
@Override public void restore(BoardId location, Object snapshot) {
|
||||
throw new AssertionError("obstructed generation must not restore");
|
||||
}
|
||||
};
|
||||
QuestAdminCommand executor = new QuestAdminCommand(
|
||||
registry,
|
||||
new PlayerCommandSettings(new MemoryPlayerCommandSettingsRepository()),
|
||||
new PhysicalBoardCreator(registry, ignored -> obstructedWorld)
|
||||
);
|
||||
Player player = mock(Player.class);
|
||||
Block anchor = mock(Block.class);
|
||||
World world = mock(World.class);
|
||||
when(player.hasPermission("spigotquestboard.admin")).thenReturn(true);
|
||||
when(player.getTargetBlockExact(5)).thenReturn(anchor);
|
||||
when(player.getFacing()).thenReturn(BlockFace.SOUTH);
|
||||
when(anchor.getWorld()).thenReturn(world);
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
when(world.getName()).thenReturn("world");
|
||||
|
||||
executor.onCommand(
|
||||
player, mock(Command.class), "questadmin",
|
||||
new String[] {"createboard", "physical"}
|
||||
);
|
||||
|
||||
assertTrue(repository.state.boards().isEmpty());
|
||||
verify(player).sendMessage(
|
||||
"The physical quest board needs 23 empty placement blocks."
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void consoleAndMissingTargetDoNotChangeState() throws Exception {
|
||||
MemoryBoardRepository repository = new MemoryBoardRepository();
|
||||
QuestAdminCommand executor = new QuestAdminCommand(new BoardRegistry(repository));
|
||||
QuestAdminCommand executor = new QuestAdminCommand(
|
||||
new BoardRegistry(repository),
|
||||
new PlayerCommandSettings(new MemoryPlayerCommandSettingsRepository())
|
||||
);
|
||||
CommandSender console = mock(CommandSender.class);
|
||||
when(console.hasPermission("spigotquestboard.admin")).thenReturn(true);
|
||||
assertTrue(executor.onCommand(console, mock(Command.class), "questadmin", new String[] {"createboard"}));
|
||||
@@ -70,6 +274,18 @@ final class QuestAdminCommandTest {
|
||||
assertTrue(repository.state.boards().isEmpty());
|
||||
}
|
||||
|
||||
private static final class MemoryPlayerCommandSettingsRepository
|
||||
implements PlayerCommandSettingsRepository {
|
||||
private boolean enabled;
|
||||
private int saves;
|
||||
|
||||
@Override public boolean loadEnabled() { return enabled; }
|
||||
@Override public void saveEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
saves++;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class MemoryBoardRepository implements BoardRepository {
|
||||
private BoardState state = new BoardState(Set.of());
|
||||
@Override public BoardState load() { return state; }
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
@@ -16,6 +19,192 @@ import org.junit.jupiter.api.Test;
|
||||
final class QuestBoardDialogUiTest {
|
||||
private static final Instant NOW = Instant.parse("2026-09-05T03:00:00Z");
|
||||
|
||||
@Test
|
||||
void dashboardUsesCompactDedicatedNavigableScreens() {
|
||||
RecordingCreator creator = new RecordingCreator(false);
|
||||
QuestBoardDialogUi ui = new QuestBoardDialogUi(
|
||||
creator, now -> List.of(creator.quest(NOW)), Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
);
|
||||
|
||||
QuestBoardDialogSpec specification = ui.specification(mock(Player.class));
|
||||
|
||||
assertEquals(420, specification.maximumBodyWidth());
|
||||
assertEquals(List.of("Browse quests", "Create quest", "Pending claims"),
|
||||
specification.dashboardActions());
|
||||
assertEquals(1, specification.questEntries().size());
|
||||
org.junit.jupiter.api.Assertions.assertTrue(
|
||||
specification.questEntries().getFirst().hasBackAction()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dedicatedScreensKeepContentSeparateAndProvideBackNavigation() {
|
||||
RecordingCreator creator = new RecordingCreator(false);
|
||||
QuestBoardDialogUi ui = new QuestBoardDialogUi(
|
||||
creator, now -> List.of(creator.quest(NOW)), null, null, player ->
|
||||
new ClaimCollectionResult(0, 0), Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
);
|
||||
|
||||
QuestBoardDialogSpec specification = ui.specification(mock(Player.class));
|
||||
|
||||
assertEquals("Choose what you want to do.", specification.dashboard().message());
|
||||
assertTrue(specification.browse().message().contains("Select a quest"));
|
||||
assertTrue(specification.create().message().contains("reward in your main hand"));
|
||||
assertEquals("No pending claims.", specification.claims().message());
|
||||
assertFalse(specification.dashboard().hasBackAction());
|
||||
assertTrue(specification.browse().hasBackAction());
|
||||
assertTrue(specification.create().hasBackAction());
|
||||
assertTrue(specification.claims().hasBackAction());
|
||||
assertTrue(specification.questEntries().stream().allMatch(
|
||||
QuestBoardDialogSpec.QuestEntry::hasBackAction
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void questDetailsShowRequiredContentAndRouteAvailableActions() {
|
||||
RecordingCreator creator = new RecordingCreator(false);
|
||||
Quest quest = creator.quest(NOW);
|
||||
Player issuer = mock(Player.class);
|
||||
when(issuer.getUniqueId()).thenReturn(quest.issuerId());
|
||||
QuestBoardDialogUi ui = new QuestBoardDialogUi(
|
||||
creator, now -> List.of(quest), new RecordingCompleter(), new RecordingCanceller(),
|
||||
null, Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
);
|
||||
|
||||
QuestBoardDialogSpec specification = ui.specification(issuer);
|
||||
QuestBoardDialogSpec.QuestEntry entry = specification.questEntries().getFirst();
|
||||
|
||||
assertTrue(entry.message().contains("64 × STONE"));
|
||||
assertTrue(entry.message().contains("Reward: 1 × DIAMOND"));
|
||||
assertTrue(entry.message().contains("Issuer: Issuer"));
|
||||
assertTrue(entry.message().contains("Time remaining: 7d"));
|
||||
assertEquals(
|
||||
List.of(QuestBoardDialogSpec.Route.COMPLETE, QuestBoardDialogSpec.Route.CANCEL),
|
||||
entry.actions().stream().map(QuestBoardDialogSpec.Action::route).toList()
|
||||
);
|
||||
assertTrue(entry.actions().stream().allMatch(
|
||||
action -> quest.id().toString().equals(action.questId())
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void dashboardCreationAndClaimsDeclareTheirActionRoutesAndInputs() {
|
||||
RecordingCreator creator = new RecordingCreator(false);
|
||||
QuestBoardDialogUi ui = new QuestBoardDialogUi(
|
||||
creator, now -> List.of(), null, null, player ->
|
||||
new ClaimCollectionResult(0, 0), Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
);
|
||||
|
||||
QuestBoardDialogSpec specification = ui.specification(mock(Player.class));
|
||||
|
||||
assertEquals(
|
||||
List.of(
|
||||
QuestBoardDialogSpec.Route.OPEN_BROWSE,
|
||||
QuestBoardDialogSpec.Route.OPEN_CREATE,
|
||||
QuestBoardDialogSpec.Route.OPEN_CLAIMS
|
||||
),
|
||||
specification.dashboard().actions().stream()
|
||||
.map(QuestBoardDialogSpec.Action::route)
|
||||
.toList()
|
||||
);
|
||||
assertEquals(List.of("requested_material", "requested_quantity"),
|
||||
specification.create().inputs().stream()
|
||||
.map(QuestBoardDialogSpec.Input::key)
|
||||
.toList());
|
||||
assertEquals(QuestBoardDialogSpec.Route.SUBMIT_CREATE,
|
||||
specification.create().actions().getFirst().route());
|
||||
assertEquals(QuestBoardDialogSpec.Route.COLLECT,
|
||||
specification.claims().actions().getFirst().route());
|
||||
}
|
||||
|
||||
@Test
|
||||
void largeQuestCollectionsRemainIndividualNavigableEntries() {
|
||||
RecordingCreator creator = new RecordingCreator(false);
|
||||
List<Quest> quests = java.util.stream.IntStream.range(0, 250)
|
||||
.mapToObj(index -> new Quest(
|
||||
new UUID(0, index + 100L), new UUID(0, index + 1L), "Issuer " + index,
|
||||
"STONE", index + 1, List.of(new EscrowItem("DIAMOND", 1, null)),
|
||||
NOW, NOW.plusSeconds(604800)
|
||||
))
|
||||
.toList();
|
||||
QuestBoardDialogUi ui = new QuestBoardDialogUi(
|
||||
creator, now -> quests, Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
);
|
||||
|
||||
QuestBoardDialogSpec specification = ui.specification(mock(Player.class));
|
||||
|
||||
assertEquals(250, specification.questEntries().size());
|
||||
assertTrue(specification.questEntries().stream().allMatch(
|
||||
QuestBoardDialogSpec.QuestEntry::hasBackAction
|
||||
));
|
||||
assertFalse(specification.browse().message().contains(quests.getLast().id().toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readOnlyBoardDoesNotOfferUnavailableMutationActions() {
|
||||
RecordingCreator creator = new RecordingCreator(false);
|
||||
Quest quest = creator.quest(NOW);
|
||||
Player issuer = mock(Player.class);
|
||||
when(issuer.getUniqueId()).thenReturn(quest.issuerId());
|
||||
QuestBoardDialogUi ui = new QuestBoardDialogUi(
|
||||
creator, now -> List.of(quest), Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
);
|
||||
assertTrue(ui.specification(issuer).questEntries().getFirst().actions().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonIssuersCannotReceiveCancellationRoutes() {
|
||||
RecordingCreator creator = new RecordingCreator(false);
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
QuestBoardDialogUi ui = new QuestBoardDialogUi(
|
||||
creator, now -> List.of(creator.quest(NOW)), new RecordingCompleter(),
|
||||
new RecordingCanceller(), Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
);
|
||||
|
||||
QuestBoardDialogSpec.QuestEntry entry = ui.specification(player)
|
||||
.questEntries().getFirst();
|
||||
|
||||
assertEquals(List.of(QuestBoardDialogSpec.Route.COMPLETE),
|
||||
entry.actions().stream().map(QuestBoardDialogSpec.Action::route).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledPlayerCommandsDoNotGateBoardUiGateways() throws Exception {
|
||||
PlayerCommandSettings settings = new PlayerCommandSettings(
|
||||
new PlayerCommandSettingsRepository() {
|
||||
@Override public boolean loadEnabled() { return false; }
|
||||
@Override public void saveEnabled(boolean enabled) { }
|
||||
}
|
||||
);
|
||||
RecordingCreator creator = new RecordingCreator(false);
|
||||
RecordingCompleter completer = new RecordingCompleter();
|
||||
RecordingCanceller canceller = new RecordingCanceller();
|
||||
java.util.concurrent.atomic.AtomicReference<Player> claimantPlayer =
|
||||
new java.util.concurrent.atomic.AtomicReference<>();
|
||||
QuestClaimGateway claimant = player -> {
|
||||
claimantPlayer.set(player);
|
||||
return new ClaimCollectionResult(0, 0);
|
||||
};
|
||||
QuestBoardDialogUi ui = new QuestBoardDialogUi(
|
||||
creator, now -> List.of(creator.quest(NOW)), completer, canceller, claimant,
|
||||
Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
);
|
||||
Player player = mock(Player.class);
|
||||
|
||||
ui.listingText();
|
||||
ui.submit(player, "stone", "1");
|
||||
ui.submitCompletion(player, "complete-id");
|
||||
ui.submitCancellation(player, "cancel-id");
|
||||
ui.submitClaim(player);
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertFalse(settings.enabled());
|
||||
assertEquals("stone", creator.material);
|
||||
assertEquals(player, completer.player);
|
||||
assertEquals(player, canceller.player);
|
||||
assertEquals(player, claimantPlayer.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dialogSubmissionUsesEquivalentCreationFlow() {
|
||||
RecordingCreator creator = new RecordingCreator(false);
|
||||
@@ -35,6 +224,119 @@ final class QuestBoardDialogUiTest {
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void completionActionUsesEquivalentCompletionFlow() {
|
||||
RecordingCreator creator = new RecordingCreator(false);
|
||||
RecordingCompleter completer = new RecordingCompleter();
|
||||
QuestBoardDialogUi ui = new QuestBoardDialogUi(
|
||||
creator, now -> List.of(), completer, Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
);
|
||||
Player player = mock(Player.class);
|
||||
String id = UUID.randomUUID().toString();
|
||||
|
||||
ui.submitCompletion(player, id);
|
||||
|
||||
assertEquals(player, completer.player);
|
||||
assertEquals(id, completer.questId);
|
||||
assertEquals(NOW, completer.completedAt);
|
||||
}
|
||||
|
||||
@Test
|
||||
void claimActionUsesEquivalentCollectionFlowAtABoard() {
|
||||
RecordingCreator creator = new RecordingCreator(false);
|
||||
java.util.concurrent.atomic.AtomicReference<Player> claimantPlayer =
|
||||
new java.util.concurrent.atomic.AtomicReference<>();
|
||||
QuestClaimGateway claimant = player -> {
|
||||
claimantPlayer.set(player);
|
||||
return new ClaimCollectionResult(1, 0);
|
||||
};
|
||||
QuestBoardDialogUi ui = new QuestBoardDialogUi(
|
||||
creator, now -> List.of(), null, null, claimant,
|
||||
Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
);
|
||||
Player player = mock(Player.class);
|
||||
|
||||
ui.submitClaim(player);
|
||||
|
||||
assertEquals(player, claimantPlayer.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancellationActionUsesEquivalentCancellationFlow() {
|
||||
RecordingCreator creator = new RecordingCreator(false);
|
||||
RecordingCanceller canceller = new RecordingCanceller();
|
||||
QuestBoardDialogUi ui = new QuestBoardDialogUi(
|
||||
creator, now -> List.of(), null, canceller, Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
);
|
||||
Player player = mock(Player.class);
|
||||
String id = UUID.randomUUID().toString();
|
||||
|
||||
ui.submitCancellation(player, id);
|
||||
|
||||
assertEquals(player, canceller.player);
|
||||
assertEquals(id, canceller.questId);
|
||||
assertEquals(NOW, canceller.cancelledAt);
|
||||
}
|
||||
|
||||
@Test
|
||||
void boardListingDistinguishesDeliveredItemsFromReturnedRewards() {
|
||||
RecordingCreator creator = new RecordingCreator(false);
|
||||
Player player = mock(Player.class);
|
||||
UUID owner = UUID.randomUUID();
|
||||
org.mockito.Mockito.when(player.getUniqueId()).thenReturn(owner);
|
||||
QuestClaimGateway claimant = new QuestClaimGateway() {
|
||||
@Override
|
||||
public ClaimCollectionResult collect(Player ignored) {
|
||||
return new ClaimCollectionResult(0, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<QuestClaim> pendingClaims(Player ignored) {
|
||||
return List.of(
|
||||
new QuestClaim(
|
||||
UUID.randomUUID(), UUID.randomUUID(), owner,
|
||||
List.of(new EscrowItem("STONE", 2, null)), NOW,
|
||||
QuestClaimSource.COMPLETION
|
||||
),
|
||||
new QuestClaim(
|
||||
UUID.randomUUID(), UUID.randomUUID(), owner,
|
||||
List.of(new EscrowItem("DIAMOND", 1, null)), NOW,
|
||||
QuestClaimSource.EXPIRATION
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
QuestBoardDialogUi ui = new QuestBoardDialogUi(
|
||||
creator, now -> List.of(), null, null, claimant,
|
||||
Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
);
|
||||
|
||||
String listing = ui.claimListingText(player);
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertTrue(listing.contains("DELIVERED ITEMS — 2 STONE"));
|
||||
org.junit.jupiter.api.Assertions.assertTrue(
|
||||
listing.contains("RETURNED REWARD (EXPIRATION) — 1 DIAMOND")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void boardOnlyOffersCancellationActionsForPlayersOwnedActiveQuests() {
|
||||
RecordingCreator creator = new RecordingCreator(false);
|
||||
Quest owned = creator.quest(NOW);
|
||||
Quest other = new Quest(
|
||||
UUID.randomUUID(), UUID.randomUUID(), "Other", "DIRT", 1,
|
||||
List.of(new EscrowItem("COAL", 1, null)), NOW, NOW.plusSeconds(604800)
|
||||
);
|
||||
QuestBoardDialogUi ui = new QuestBoardDialogUi(
|
||||
creator, now -> List.of(owned, other), null, new RecordingCanceller(),
|
||||
Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
);
|
||||
Player issuer = mock(Player.class);
|
||||
org.mockito.Mockito.when(issuer.getUniqueId()).thenReturn(owned.issuerId());
|
||||
|
||||
assertEquals(List.of(owned.id().toString()), ui.cancellableQuestIds(issuer));
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyOpeningBuildsAListingFromTheCurrentActiveQuests() {
|
||||
RecordingCreator creator = new RecordingCreator(false);
|
||||
@@ -60,6 +362,34 @@ final class QuestBoardDialogUiTest {
|
||||
);
|
||||
}
|
||||
|
||||
private static final class RecordingCanceller implements QuestCancellationGateway {
|
||||
private Player player;
|
||||
private String questId;
|
||||
private Instant cancelledAt;
|
||||
|
||||
@Override
|
||||
public QuestClaim cancel(Player cancellingPlayer, String id, Instant instant) {
|
||||
player = cancellingPlayer;
|
||||
questId = id;
|
||||
cancelledAt = instant;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingCompleter implements QuestCompletionGateway {
|
||||
private Player player;
|
||||
private String questId;
|
||||
private Instant completedAt;
|
||||
|
||||
@Override
|
||||
public QuestCompletion complete(Player completingPlayer, String id, Instant instant) {
|
||||
player = completingPlayer;
|
||||
questId = id;
|
||||
completedAt = instant;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingCreator implements QuestCreationGateway {
|
||||
private final boolean fail;
|
||||
private String material;
|
||||
@@ -94,7 +424,7 @@ final class QuestBoardDialogUiTest {
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> suggestBlockMaterials(String prefix) {
|
||||
public List<String> suggestItemMaterials(String prefix) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class QuestCancellationControllerTest {
|
||||
@Test
|
||||
void cancelsAsPlayerIdentityAndCreatesClaimWithoutInventorySettlement() throws Exception {
|
||||
Repository repository = new Repository();
|
||||
QuestService service = new QuestService(repository);
|
||||
UUID issuer = UUID.randomUUID();
|
||||
Quest quest = service.create(
|
||||
issuer, "Issuer", "STONE", 1,
|
||||
List.of(new EscrowItem("DIAMOND", 2, "exact-data")), Instant.EPOCH
|
||||
);
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(issuer);
|
||||
|
||||
QuestClaim claim = new QuestCancellationController(service).cancel(
|
||||
player, quest.id().toString(), Instant.EPOCH.plusSeconds(1)
|
||||
);
|
||||
|
||||
assertEquals(quest.reward(), claim.items());
|
||||
assertEquals(List.of(claim), service.state().claims().get(issuer));
|
||||
verify(player).sendMessage(contains("claim"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMalformedQuestIdentifier() throws Exception {
|
||||
QuestCancellationController controller = new QuestCancellationController(
|
||||
new QuestService(new Repository())
|
||||
);
|
||||
|
||||
IllegalArgumentException exception = assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> controller.cancel(mock(Player.class), "not-a-uuid", Instant.EPOCH)
|
||||
);
|
||||
|
||||
assertEquals("Quest identifier must be a valid UUID.", exception.getMessage());
|
||||
}
|
||||
|
||||
private static final class Repository implements QuestRepository {
|
||||
private QuestState state = QuestState.empty();
|
||||
@Override public QuestState load() { return state; }
|
||||
@Override public void save(QuestState candidate) throws IOException { state = candidate; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class QuestCancellationServiceTest {
|
||||
@Test
|
||||
void onlyIssuerCanCancelAndExactRewardBecomesClaim() throws Exception {
|
||||
Repository repository = new Repository();
|
||||
QuestService service = new QuestService(repository);
|
||||
UUID issuer = UUID.randomUUID();
|
||||
EscrowItem reward = new EscrowItem("DIAMOND", 3, "exact-item-data");
|
||||
Quest quest = service.create(
|
||||
issuer, "Issuer", "STONE", 64, List.of(reward), Instant.EPOCH
|
||||
);
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> service.cancel(
|
||||
quest.id(), UUID.randomUUID(), Instant.EPOCH.plusSeconds(1)
|
||||
));
|
||||
QuestClaim claim = service.cancel(
|
||||
quest.id(), issuer, Instant.EPOCH.plusSeconds(1)
|
||||
);
|
||||
|
||||
assertEquals(QuestStatus.CANCELLED, service.state().quests().get(quest.id()).status());
|
||||
assertEquals(List.of(reward), claim.items());
|
||||
assertEquals(List.of(claim), service.state().claims().get(issuer));
|
||||
assertTrue(service.activeQuests(Instant.EPOCH.plusSeconds(1)).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completedCancelledAndExpiredQuestsCannotBeCancelled() throws Exception {
|
||||
Repository repository = new Repository();
|
||||
QuestService service = new QuestService(repository);
|
||||
UUID issuer = UUID.randomUUID();
|
||||
Quest completed = quest(service, issuer);
|
||||
service.complete(
|
||||
completed.id(), new EscrowItem("STONE", 1, null), Instant.EPOCH.plusSeconds(1)
|
||||
);
|
||||
Quest cancelled = quest(service, issuer);
|
||||
service.cancel(cancelled.id(), issuer, Instant.EPOCH.plusSeconds(1));
|
||||
Quest expired = quest(service, issuer);
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> service.cancel(
|
||||
completed.id(), issuer, Instant.EPOCH.plusSeconds(2)
|
||||
));
|
||||
assertThrows(IllegalStateException.class, () -> service.cancel(
|
||||
cancelled.id(), issuer, Instant.EPOCH.plusSeconds(2)
|
||||
));
|
||||
assertThrows(IllegalStateException.class, () -> service.cancel(
|
||||
expired.id(), issuer, expired.expiresAt()
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistenceFailureLeavesQuestActiveAndRewardEscrowed() throws Exception {
|
||||
Repository repository = new Repository();
|
||||
QuestService service = new QuestService(repository);
|
||||
UUID issuer = UUID.randomUUID();
|
||||
Quest quest = quest(service, issuer);
|
||||
QuestState before = service.state();
|
||||
repository.fail = true;
|
||||
|
||||
assertThrows(IOException.class, () -> service.cancel(
|
||||
quest.id(), issuer, Instant.EPOCH.plusSeconds(1)
|
||||
));
|
||||
|
||||
assertEquals(before, service.state());
|
||||
assertEquals(before, repository.state);
|
||||
assertEquals(QuestStatus.ACTIVE, service.state().quests().get(quest.id()).status());
|
||||
assertTrue(service.state().claims().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void simultaneousCancellationAndCompletionSettleEscrowExactlyOnce() throws Exception {
|
||||
Repository repository = new Repository();
|
||||
QuestService service = new QuestService(repository);
|
||||
UUID issuer = UUID.randomUUID();
|
||||
Quest quest = quest(service, issuer);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
|
||||
List<Future<Boolean>> attempts = new ArrayList<>();
|
||||
attempts.add(executor.submit(() -> {
|
||||
start.await();
|
||||
try {
|
||||
service.cancel(quest.id(), issuer, Instant.EPOCH.plusSeconds(1));
|
||||
return true;
|
||||
} catch (IllegalStateException exception) {
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
attempts.add(executor.submit(() -> {
|
||||
start.await();
|
||||
try {
|
||||
service.complete(
|
||||
quest.id(), new EscrowItem("STONE", 1, null),
|
||||
Instant.EPOCH.plusSeconds(1)
|
||||
);
|
||||
return true;
|
||||
} catch (IllegalStateException exception) {
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
start.countDown();
|
||||
assertEquals(1, attempts.stream().filter(attempt -> {
|
||||
try {
|
||||
return attempt.get();
|
||||
} catch (Exception exception) {
|
||||
throw new AssertionError(exception);
|
||||
}
|
||||
}).count());
|
||||
}
|
||||
|
||||
QuestStatus status = service.state().quests().get(quest.id()).status();
|
||||
assertTrue(status == QuestStatus.CANCELLED || status == QuestStatus.COMPLETED);
|
||||
assertEquals(1, service.state().claims().get(issuer).size());
|
||||
QuestClaim settlement = service.state().claims().get(issuer).getFirst();
|
||||
if (status == QuestStatus.CANCELLED) {
|
||||
assertEquals(quest.reward(), settlement.items());
|
||||
assertTrue(service.state().notifications().isEmpty());
|
||||
} else {
|
||||
assertEquals(List.of(new EscrowItem("STONE", 1, null)), settlement.items());
|
||||
assertEquals(1, service.state().notifications().size());
|
||||
}
|
||||
}
|
||||
|
||||
private static Quest quest(QuestService service, UUID issuer) throws IOException {
|
||||
return service.create(
|
||||
issuer, "Issuer", "STONE", 1,
|
||||
List.of(new EscrowItem("DIAMOND", 2, null)), Instant.EPOCH
|
||||
);
|
||||
}
|
||||
|
||||
private static final class Repository implements QuestRepository {
|
||||
private QuestState state = QuestState.empty();
|
||||
private boolean fail;
|
||||
|
||||
@Override public QuestState load() { return state; }
|
||||
|
||||
@Override
|
||||
public void save(QuestState candidate) throws IOException {
|
||||
if (fail) {
|
||||
throw new IOException("disk full");
|
||||
}
|
||||
state = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class QuestClaimControllerTest {
|
||||
@Test
|
||||
void collectsEveryClaimAndClearlyDistinguishesItemsAndOverflow() throws Exception {
|
||||
Repository repository = new Repository();
|
||||
QuestService service = serviceWithAllClaimSources(repository);
|
||||
UUID issuer = service.state().claims().keySet().iterator().next();
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(issuer);
|
||||
RecordingInventory inventory = new RecordingInventory(2);
|
||||
|
||||
ClaimCollectionResult result = new QuestClaimController(service, inventory).collect(player);
|
||||
|
||||
assertEquals(new ClaimCollectionResult(3, 6), result);
|
||||
assertTrue(service.claimsFor(issuer).isEmpty());
|
||||
verify(player).sendMessage(contains("delivered items from completed quest"));
|
||||
verify(player).sendMessage(contains("returned reward from cancelled quest"));
|
||||
verify(player).sendMessage(contains("returned reward from expired quest"));
|
||||
verify(player).sendMessage(contains("dropped at your feet, protected for you"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deliveryFailureLeavesClaimPendingForRetry() throws Exception {
|
||||
Repository repository = new Repository();
|
||||
QuestService service = expiredService(repository);
|
||||
UUID issuer = service.state().claims().keySet().iterator().next();
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(issuer);
|
||||
QuestClaimInventory inventory = items -> ignored -> {
|
||||
throw new IllegalStateException("world rejected drop");
|
||||
};
|
||||
|
||||
assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> new QuestClaimController(service, inventory).collect(player)
|
||||
);
|
||||
|
||||
assertEquals(1, service.claimsFor(issuer).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void acknowledgementFailureRollsBackDeliveryAndLeavesClaimPending() throws Exception {
|
||||
Repository repository = new Repository();
|
||||
QuestService service = expiredService(repository);
|
||||
UUID issuer = service.state().claims().keySet().iterator().next();
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(issuer);
|
||||
RecordingInventory inventory = new RecordingInventory(0);
|
||||
repository.fail = true;
|
||||
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> new QuestClaimController(service, inventory).collect(player)
|
||||
);
|
||||
|
||||
assertEquals(1, inventory.rollbacks);
|
||||
assertEquals(1, service.claimsFor(issuer).size());
|
||||
}
|
||||
|
||||
private static QuestService serviceWithAllClaimSources(Repository repository) throws Exception {
|
||||
QuestService service = new QuestService(repository);
|
||||
UUID issuer = UUID.randomUUID();
|
||||
Quest completed = create(service, issuer, "STONE");
|
||||
Quest cancelled = create(service, issuer, "DIRT");
|
||||
Quest expired = create(service, issuer, "SAND");
|
||||
service.complete(
|
||||
completed.id(), new EscrowItem("STONE", 1, null), Instant.EPOCH.plusSeconds(1)
|
||||
);
|
||||
service.cancel(cancelled.id(), issuer, Instant.EPOCH.plusSeconds(1));
|
||||
service.expire(expired.expiresAt());
|
||||
return service;
|
||||
}
|
||||
|
||||
private static QuestService expiredService(Repository repository) throws Exception {
|
||||
QuestService service = new QuestService(repository);
|
||||
Quest quest = create(service, UUID.randomUUID(), "STONE");
|
||||
service.expire(quest.expiresAt());
|
||||
return service;
|
||||
}
|
||||
|
||||
private static Quest create(QuestService service, UUID issuer, String material)
|
||||
throws IOException {
|
||||
return service.create(
|
||||
issuer, "Issuer", material, 1,
|
||||
List.of(new EscrowItem("DIAMOND", 1, null)), Instant.EPOCH
|
||||
);
|
||||
}
|
||||
|
||||
private static final class RecordingInventory implements QuestClaimInventory {
|
||||
private final int overflow;
|
||||
private int rollbacks;
|
||||
|
||||
private RecordingInventory(int overflow) {
|
||||
this.overflow = overflow;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PreparedClaim prepare(List<EscrowItem> items) {
|
||||
return player -> new Delivery() {
|
||||
@Override public int overflowAmount() { return overflow; }
|
||||
@Override public void rollback() { rollbacks++; }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Repository implements QuestRepository {
|
||||
private QuestState state = QuestState.empty();
|
||||
private boolean fail;
|
||||
@Override public QuestState load() { return state; }
|
||||
@Override public void save(QuestState candidate) throws IOException {
|
||||
if (fail) {
|
||||
throw new IOException("disk full");
|
||||
}
|
||||
state = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Clock;
|
||||
@@ -13,12 +14,147 @@ import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class QuestCommandTest {
|
||||
private static final Instant NOW = Instant.parse("2026-09-05T03:00:00Z");
|
||||
private static final UUID WORLD_ID =
|
||||
UUID.fromString("00000000-0000-0000-0000-000000000099");
|
||||
|
||||
@Test
|
||||
void disabledSettingGatesNonCreationCommandForms() throws Exception {
|
||||
QuestCreationGateway creator = mock(QuestCreationGateway.class);
|
||||
QuestBrowser browser = mock(QuestBrowser.class);
|
||||
QuestCompletionGateway completer = mock(QuestCompletionGateway.class);
|
||||
QuestCancellationGateway canceller = mock(QuestCancellationGateway.class);
|
||||
QuestClaimGateway claimant = mock(QuestClaimGateway.class);
|
||||
PlayerCommandSettings settings = new PlayerCommandSettings(
|
||||
new PlayerCommandSettingsRepository() {
|
||||
@Override public boolean loadEnabled() { return false; }
|
||||
@Override public void saveEnabled(boolean enabled) { }
|
||||
}
|
||||
);
|
||||
QuestCommand executor = new QuestCommand(
|
||||
creator, browser, completer, canceller, claimant,
|
||||
Clock.fixed(NOW, ZoneOffset.UTC), settings
|
||||
);
|
||||
Player player = mock(Player.class);
|
||||
Command command = mock(Command.class);
|
||||
List<String[]> forms = List.of(
|
||||
new String[] {},
|
||||
new String[] {"list"},
|
||||
new String[] {"complete", "quest-id"},
|
||||
new String[] {"cancel", "quest-id"},
|
||||
new String[] {"claim"}
|
||||
);
|
||||
|
||||
for (String[] form : forms) {
|
||||
assertTrue(executor.onCommand(player, command, "quests", form));
|
||||
}
|
||||
|
||||
verify(player, times(forms.size())).sendMessage(
|
||||
"Player quest commands are disabled. Use a physical quest board instead."
|
||||
);
|
||||
assertTrue(executor.onTabComplete(
|
||||
player, command, "quests", new String[] {""}
|
||||
).isEmpty());
|
||||
verifyNoInteractions(creator, browser, completer, canceller, claimant);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledSettingAllowsNearbyCreationAndOnlyCreationAutocomplete() throws Exception {
|
||||
RecordingCreator creator = new RecordingCreator();
|
||||
PlayerCommandSettings settings = settings(false);
|
||||
BoardProximity proximity = (worldId, x, y, z, maximumDistance) -> {
|
||||
assertEquals(WORLD_ID, worldId);
|
||||
assertEquals(4.0, x);
|
||||
assertEquals(3.0, y);
|
||||
assertEquals(0.0, z);
|
||||
assertEquals(5.0, maximumDistance);
|
||||
return true;
|
||||
};
|
||||
QuestCommand executor = new QuestCommand(
|
||||
creator, now -> List.of(), null, null, null,
|
||||
Clock.fixed(NOW, ZoneOffset.UTC), settings, proximity
|
||||
);
|
||||
Player player = playerAt(4.0, 3.0, 0.0);
|
||||
Command command = mock(Command.class);
|
||||
|
||||
assertTrue(executor.onCommand(
|
||||
player, command, "quests", new String[] {"create", "stone", "1"}
|
||||
));
|
||||
|
||||
assertEquals(1, creator.calls);
|
||||
assertEquals(List.of("create"), executor.onTabComplete(
|
||||
player, command, "quests", new String[] {""}
|
||||
));
|
||||
assertTrue(executor.onTabComplete(
|
||||
player, command, "quests", new String[] {"li"}
|
||||
).isEmpty());
|
||||
assertEquals(List.of("STONE", "STONE_BRICKS"), executor.onTabComplete(
|
||||
player, command, "quests", new String[] {"create", "sto"}
|
||||
));
|
||||
assertEquals(List.of("1", "16", "32", "64"), executor.onTabComplete(
|
||||
player, command, "quests", new String[] {"create", "stone", ""}
|
||||
));
|
||||
assertTrue(executor.onTabComplete(
|
||||
player, command, "quests", new String[] {"complete", ""}
|
||||
).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledSettingRejectsAwayCreationBeforeCreatorOrEscrowGateway() throws Exception {
|
||||
QuestCreationGateway creator = mock(QuestCreationGateway.class);
|
||||
QuestCommand executor = new QuestCommand(
|
||||
creator, mock(QuestBrowser.class), null, null, null,
|
||||
Clock.fixed(NOW, ZoneOffset.UTC), settings(false),
|
||||
(worldId, x, y, z, maximumDistance) -> false
|
||||
);
|
||||
Player player = playerAt(5.01, 0.0, 0.0);
|
||||
Command command = mock(Command.class);
|
||||
|
||||
assertTrue(executor.onCommand(
|
||||
player, command, "quests", new String[] {"create", "stone", "1"}
|
||||
));
|
||||
|
||||
verify(player).sendMessage("Move closer to a quest board to create a quest by command.");
|
||||
verifyNoInteractions(creator);
|
||||
assertTrue(executor.onTabComplete(
|
||||
player, command, "quests", new String[] {""}
|
||||
).isEmpty());
|
||||
assertTrue(executor.onTabComplete(
|
||||
player, command, "quests", new String[] {"create", "sto"}
|
||||
).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void enabledSettingPreservesAllCommandsWithoutConsultingProximity() throws Exception {
|
||||
RecordingCreator creator = new RecordingCreator();
|
||||
QuestBrowser browser = now -> List.of(creator.quest(1));
|
||||
QuestCommand executor = new QuestCommand(
|
||||
creator, browser, null, null, null, Clock.fixed(NOW, ZoneOffset.UTC),
|
||||
settings(true),
|
||||
(worldId, x, y, z, maximumDistance) -> {
|
||||
throw new AssertionError("Enabled commands must bypass board proximity");
|
||||
}
|
||||
);
|
||||
Player player = mock(Player.class);
|
||||
Command command = mock(Command.class);
|
||||
|
||||
assertTrue(executor.onCommand(
|
||||
player, command, "quests", new String[] {"create", "stone", "1"}
|
||||
));
|
||||
assertTrue(executor.onCommand(player, command, "quests", new String[] {"list"}));
|
||||
assertEquals(List.of("create"), executor.onTabComplete(
|
||||
player, command, "quests", new String[] {"cr"}
|
||||
));
|
||||
|
||||
assertEquals(1, creator.calls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void routesValidatedCreateArgumentsWithCurrentUtcTime() {
|
||||
@@ -86,6 +222,66 @@ final class QuestCommandTest {
|
||||
verify(player).sendMessage("Quest quantity must be a positive whole number.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void completeCommandRoutesPlayerAndCurrentTimeToEquivalentGateway() {
|
||||
RecordingCreator creator = new RecordingCreator();
|
||||
RecordingCompleter completer = new RecordingCompleter();
|
||||
QuestCommand executor = new QuestCommand(
|
||||
creator, now -> List.of(), completer, Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
);
|
||||
Player player = mock(Player.class);
|
||||
String id = UUID.randomUUID().toString();
|
||||
|
||||
assertTrue(executor.onCommand(
|
||||
player, mock(Command.class), "quests", new String[] {"complete", id}
|
||||
));
|
||||
|
||||
assertEquals(player, completer.player);
|
||||
assertEquals(id, completer.questId);
|
||||
assertEquals(NOW, completer.completedAt);
|
||||
}
|
||||
|
||||
@Test
|
||||
void claimCommandUsesTheSameCollectionGateway() {
|
||||
RecordingCreator creator = new RecordingCreator();
|
||||
Player player = mock(Player.class);
|
||||
java.util.concurrent.atomic.AtomicReference<Player> claimantPlayer =
|
||||
new java.util.concurrent.atomic.AtomicReference<>();
|
||||
QuestClaimGateway claimant = claimingPlayer -> {
|
||||
claimantPlayer.set(claimingPlayer);
|
||||
return new ClaimCollectionResult(1, 0);
|
||||
};
|
||||
QuestCommand executor = new QuestCommand(
|
||||
creator, now -> List.of(), null, null, claimant,
|
||||
Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
);
|
||||
|
||||
assertTrue(executor.onCommand(
|
||||
player, mock(Command.class), "quests", new String[] {"claim"}
|
||||
));
|
||||
|
||||
assertEquals(player, claimantPlayer.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelCommandRoutesPlayerAndCurrentTimeToEquivalentGateway() {
|
||||
RecordingCreator creator = new RecordingCreator();
|
||||
RecordingCanceller canceller = new RecordingCanceller();
|
||||
QuestCommand executor = new QuestCommand(
|
||||
creator, now -> List.of(), null, canceller, Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
);
|
||||
Player player = mock(Player.class);
|
||||
String id = UUID.randomUUID().toString();
|
||||
|
||||
assertTrue(executor.onCommand(
|
||||
player, mock(Command.class), "quests", new String[] {"cancel", id}
|
||||
));
|
||||
|
||||
assertEquals(player, canceller.player);
|
||||
assertEquals(id, canceller.questId);
|
||||
assertEquals(NOW, canceller.cancelledAt);
|
||||
}
|
||||
|
||||
@Test
|
||||
void questIdentifierAutocompleteOnlyUsesActiveAndOwnedQuests() {
|
||||
RecordingCreator creator = new RecordingCreator();
|
||||
@@ -120,6 +316,10 @@ final class QuestCommandTest {
|
||||
executor.onTabComplete(player, command, "quests", new String[] {"cr"}));
|
||||
assertEquals(List.of("list"),
|
||||
executor.onTabComplete(player, command, "quests", new String[] {"li"}));
|
||||
assertEquals(List.of("cancel"),
|
||||
executor.onTabComplete(player, command, "quests", new String[] {"ca"}));
|
||||
assertEquals(List.of("claim"),
|
||||
executor.onTabComplete(player, command, "quests", new String[] {"cl"}));
|
||||
assertEquals(List.of("STONE", "STONE_BRICKS"), executor.onTabComplete(
|
||||
player, command, "quests", new String[] {"create", "sto"}
|
||||
));
|
||||
@@ -132,6 +332,23 @@ final class QuestCommandTest {
|
||||
).isEmpty());
|
||||
}
|
||||
|
||||
private static PlayerCommandSettings settings(boolean enabled) throws Exception {
|
||||
return new PlayerCommandSettings(new PlayerCommandSettingsRepository() {
|
||||
@Override public boolean loadEnabled() { return enabled; }
|
||||
@Override public void saveEnabled(boolean newValue) { }
|
||||
});
|
||||
}
|
||||
|
||||
private static Player playerAt(double x, double y, double z) {
|
||||
World world = mock(World.class);
|
||||
when(world.getUID()).thenReturn(WORLD_ID);
|
||||
Location location = new Location(world, x, y, z);
|
||||
Player player = mock(Player.class);
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(player.getLocation()).thenReturn(location);
|
||||
return player;
|
||||
}
|
||||
|
||||
private static QuestCommand command(RecordingCreator creator) {
|
||||
return command(creator, now -> List.of());
|
||||
}
|
||||
@@ -140,6 +357,34 @@ final class QuestCommandTest {
|
||||
return new QuestCommand(creator, browser, Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
private static final class RecordingCanceller implements QuestCancellationGateway {
|
||||
private Player player;
|
||||
private String questId;
|
||||
private Instant cancelledAt;
|
||||
|
||||
@Override
|
||||
public QuestClaim cancel(Player cancellingPlayer, String id, Instant instant) {
|
||||
player = cancellingPlayer;
|
||||
questId = id;
|
||||
cancelledAt = instant;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingCompleter implements QuestCompletionGateway {
|
||||
private Player player;
|
||||
private String questId;
|
||||
private Instant completedAt;
|
||||
|
||||
@Override
|
||||
public QuestCompletion complete(Player completingPlayer, String id, Instant instant) {
|
||||
player = completingPlayer;
|
||||
questId = id;
|
||||
completedAt = instant;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingCreator implements QuestCreationGateway {
|
||||
private Player player;
|
||||
private String material;
|
||||
@@ -172,7 +417,7 @@ final class QuestCommandTest {
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> suggestBlockMaterials(String prefix) {
|
||||
public List<String> suggestItemMaterials(String prefix) {
|
||||
suggestionPrefix = prefix;
|
||||
return List.of("STONE", "STONE_BRICKS");
|
||||
}
|
||||
|
||||
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user