Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4abddb5d4d | ||
|
|
4d90add014 | ||
|
|
bf7f01a39e | ||
|
|
3bd2dc7d14 | ||
|
|
de8ff83c3c | ||
|
|
02f74d1822 | ||
|
|
e12e31c0ad | ||
|
|
07061a3622 | ||
|
|
72e58383a2 |
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,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`. They can instead run `/questadmin createboard physical` to generate a five-wide oak board above the targeted ground anchor and register its visible panel blocks and signs. Right-clicking any registered board opens a compact native dashboard with dedicated browsing, creation, and claim screens. 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. Players can complete quests at any board or with `/quests complete <quest>` by delivering the required blocks. Exact escrowed rewards are granted immediately, and delivered blocks 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.
|
||||
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
|
||||
|
||||
@@ -23,7 +49,7 @@ The plugin JAR is written to `build/libs/`.
|
||||
|
||||
## Player commands
|
||||
|
||||
Player commands are disabled by default and can be enabled persistently by an administrator. When disabled, players are directed to use a physical quest board.
|
||||
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
|
||||
|
||||
@@ -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,93 +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`.
|
||||
|
||||
## 2026-09-05 — Block delivery and quest completion
|
||||
|
||||
- Added board actions and `/quests complete <quest>` for exact requested-material delivery from player inventories.
|
||||
- Completion serializes state transitions, holds delivered blocks in durable issuer claims, and grants exact escrowed rewards with owner-protected overflow drops.
|
||||
- Added durable online and next-login issuer notifications and rollback before persistence succeeds.
|
||||
- Verified 49 tests and the plugin JAR with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-09-05 — Issuer quest cancellation
|
||||
|
||||
- Added issuer-only board actions and `/quests cancel <quest>` with ownership-filtered autocomplete.
|
||||
- Cancellation atomically moves exact reward escrow into an issuer claim without directly changing inventory.
|
||||
- Serialized cancellation against completion and retained active state when persistence fails.
|
||||
- Verified 58 tests and the plugin JAR with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-09-05 — Quest expiration and held-item claims
|
||||
|
||||
- Added boundary-safe scheduled expiration that moves exact rewards into durable issuer claims.
|
||||
- Added claim collection at every board and through `/quests claim`, distinguishing delivered blocks from cancelled or expired reward returns.
|
||||
- Inventory overflow drops at the claimant's feet with ownership protection, and failed claim acknowledgement rolls inventory and drops back before retry.
|
||||
- Added persisted online and next-login expiry notifications.
|
||||
- Verified 73 tests and the plugin JAR with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-09-05 — Administrative player-command control
|
||||
|
||||
- Disabled all player `/quests` command forms and autocomplete by default while preserving equivalent physical-board actions.
|
||||
- Added persistent `/questadmin commands enable|disable` control with permission-aware autocomplete and failure-safe updates.
|
||||
- Kept administrative board creation available independently of the player-command setting.
|
||||
- Verified 82 tests and the plugin JAR with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-09-05 — Screen-fitting quest-board dialogs
|
||||
|
||||
- Replaced the clipped 800-pixel combined board screen with a compact 420-pixel dashboard.
|
||||
- Split browsing, creation, and claims into dedicated native dialog screens with Back navigation.
|
||||
- Presented active quests as individual detail dialogs while retaining completion and issuer cancellation actions.
|
||||
- Verified 88 tests, including a 250-quest navigation case, and the plugin JAR with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-09-05 — Generated physical quest boards
|
||||
|
||||
- Added `/questadmin createboard physical` to generate a five-wide, four-tall board above the targeted ground anchor facing the administrator.
|
||||
- Used oak-log pillars, an oak-plank face, and three oak wall signs with obfuscated decorative text.
|
||||
- Registered all visible planks and signs in one durable operation and added obstruction checks plus block rollback when construction or persistence fails.
|
||||
- Preserved existing single-block board creation and persisted board compatibility.
|
||||
- Verified 103 tests and the plugin JAR with `./gradlew clean check jar`.
|
||||
@@ -1,18 +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)
|
||||
9. [US-009: Use a screen-fitting quest-board interface](us-009-use-a-screen-fitting-quest-board-interface.md)
|
||||
10. [US-010: Generate a physical quest-board structure](us-010-generate-a-physical-quest-board.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: done
|
||||
---
|
||||
|
||||
# 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
|
||||
|
||||
- [x] An active quest offers a Complete Quest action through every registered board.
|
||||
- [x] Completion requires the delivering player to possess the full requested quantity of the exact block material.
|
||||
- [x] Successful completion removes the requested blocks from the delivering player's inventory and stores them as a claim for the issuer.
|
||||
- [x] Successful completion gives the exact escrowed reward items to the delivering player.
|
||||
- [x] Reward items that do not fit are dropped safely at the delivering player's feet with a clear message.
|
||||
- [x] The issuer is notified immediately when online or on their next login when offline that delivered blocks can be claimed at a quest board.
|
||||
- [x] When player commands are enabled, `/quests complete <quest>` provides equivalent behavior with contextual autocomplete.
|
||||
- [x] Completion is atomic and exactly one player can complete a quest, including simultaneous attempts.
|
||||
- [x] Validation or persistence failure does not consume delivered blocks, release rewards, or change quest state.
|
||||
- [x] Completed quests are no longer shown as active or completable.
|
||||
- [x] 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: done
|
||||
---
|
||||
|
||||
# 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
|
||||
|
||||
- [x] An issuer can cancel their own active quest through any registered board.
|
||||
- [x] A player cannot cancel a quest issued by another player.
|
||||
- [x] Completed, cancelled, and expired quests cannot be cancelled.
|
||||
- [x] Cancellation removes the quest from active listings and converts its exact escrowed reward into a claim for the issuer.
|
||||
- [x] The reward is not inserted directly into the issuer's inventory during cancellation.
|
||||
- [x] When player commands are enabled, `/quests cancel <quest>` provides equivalent behavior and only autocompletes the player's cancellable quest identifiers.
|
||||
- [x] Cancellation and simultaneous completion are serialized so items cannot be duplicated or lost.
|
||||
- [x] Persistence failure leaves the quest active and its reward escrowed.
|
||||
- [x] 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: done
|
||||
---
|
||||
|
||||
# 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
|
||||
|
||||
- [x] An uncompleted quest expires seven days after its creation time.
|
||||
- [x] Expiration removes the quest from active listings and converts its exact escrowed reward into a claim for the issuer.
|
||||
- [x] Completed-quest claims contain the exact block items delivered by the completing player.
|
||||
- [x] Claims from completion, cancellation, and expiration can be collected through any registered board.
|
||||
- [x] The board clearly distinguishes delivered blocks from returned rewards.
|
||||
- [x] Online issuers receive a real-time message when items become claimable.
|
||||
- [x] Offline issuers receive a message on their next login.
|
||||
- [x] When player commands are enabled, `/quests claim` collects the same pending claims.
|
||||
- [x] Items are inserted into the claimant's inventory where space permits; overflow is dropped safely at their feet with a clear message.
|
||||
- [x] A claim is removed only after all of its items have been inserted or dropped successfully.
|
||||
- [x] Pending claims and notification state survive logout and server restart without duplication or loss.
|
||||
- [x] 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: done
|
||||
---
|
||||
|
||||
# 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
|
||||
|
||||
- [x] Player `/quests` commands are disabled by default.
|
||||
- [x] An authorized administrator can use `/questadmin commands enable|disable` with contextual autocomplete.
|
||||
- [x] The command setting persists across server restarts.
|
||||
- [x] When disabled, `/quests`, `list`, `create`, `complete`, `cancel`, and `claim` reject execution with a clear message directing the player to a quest board.
|
||||
- [x] Disabling commands does not prevent any equivalent action through a registered board.
|
||||
- [x] `/questadmin` remains available to authorized administrators regardless of the player-command setting.
|
||||
- [x] Unauthorized users cannot change the setting.
|
||||
- [x] 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,30 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-009: Use a screen-fitting quest-board interface"
|
||||
description: Split the oversized quest-board dialog into compact navigable screens that fit the player's display.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-009: Use a screen-fitting quest-board interface
|
||||
|
||||
As a **player**, I want the quest-board interface to fit on screen so that I can read quest information and reach every action.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] The oversized combined dialog is replaced by a compact dashboard.
|
||||
- [x] Dialog message bodies use a maximum width of 420 pixels.
|
||||
- [x] Browsing, quest creation, and pending claims use dedicated screens rather than one combined body.
|
||||
- [x] Active quests are presented as individual navigable entries instead of one unbounded text listing.
|
||||
- [x] Each quest detail shows its issuer, requested material and quantity, reward, and time remaining.
|
||||
- [x] Completion, issuer-only cancellation, creation, and claim collection remain available through the board.
|
||||
- [x] Nested screens provide Back controls that return toward the quest-board dashboard.
|
||||
- [x] Large active-quest collections remain navigable without producing an unbounded single dialog.
|
||||
- [x] Automated tests verify width limits, screen separation, navigation, displayed content, and action routing.
|
||||
|
||||
## 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-004: Browse available quests](us-004-browse-available-quests.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,29 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-010: Generate a physical quest-board structure"
|
||||
description: Let an administrator generate and register a decorative oak quest board at a targeted ground anchor.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-010: Generate a physical quest-board structure
|
||||
|
||||
As an **administrator**, I want quest-board creation to optionally construct a recognizable physical board so that I do not need to build each board manually.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `/questadmin createboard physical` generates and registers a physical quest board while the existing `/questadmin createboard` behavior remains unchanged.
|
||||
- [x] The targeted block is treated as the ground anchor and remains unchanged.
|
||||
- [x] The generated board faces the administrator and is five blocks wide and four blocks tall.
|
||||
- [x] The outer columns use oak-log pillars and the center uses oak planks.
|
||||
- [x] Oak wall signs on the front display decorative obfuscated or gibberish text.
|
||||
- [x] Clicking generated visible planks or signs opens the same globally shared quest-board interface.
|
||||
- [x] Generation requires empty space and refuses to overwrite an existing structure.
|
||||
- [x] Structure generation and interaction-location registration form one failure-safe transaction; failure restores changed blocks and registers nothing.
|
||||
- [x] Generated interaction locations persist across restarts and remain compatible with existing registered boards.
|
||||
- [x] The `physical` argument is offered through permission-aware command autocomplete.
|
||||
- [x] Automated tests verify geometry, facing direction, obstruction handling, rollback, registration, persistence, and autocomplete.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-002: Create and use shared quest boards](us-002-create-and-use-shared-quest-boards.md)
|
||||
- [US-009: Use a screen-fitting quest-board interface](us-009-use-a-screen-fitting-quest-board-interface.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,8 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@FunctionalInterface
|
||||
interface BoardProximity {
|
||||
boolean isWithin(UUID worldId, double x, double y, double z, double maximumDistance);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ 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;
|
||||
|
||||
@@ -46,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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
@@ -15,11 +16,17 @@ 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
|
||||
@@ -32,7 +39,20 @@ final class BukkitIssuerNotifier implements IssuerNotifier, Listener {
|
||||
|
||||
@EventHandler
|
||||
public void onPlayerJoin(PlayerJoinEvent event) {
|
||||
deliver(event.getPlayer());
|
||||
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) {
|
||||
|
||||
+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()
|
||||
@@ -1,5 +1,6 @@
|
||||
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;
|
||||
@@ -12,11 +13,19 @@ 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) {
|
||||
@@ -55,14 +64,53 @@ final class BukkitPhysicalBoardWorld implements PhysicalBoardWorld {
|
||||
if (!(state instanceof Sign sign)) {
|
||||
throw new IllegalStateException("Oak wall sign did not create sign state");
|
||||
}
|
||||
for (int line = 0; line < 4; line++) {
|
||||
sign.getSide(Side.FRONT).line(line, DECORATIVE_TEXT);
|
||||
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)) {
|
||||
@@ -70,6 +118,16 @@ final class BukkitPhysicalBoardWorld implements PhysicalBoardWorld {
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
@@ -22,8 +22,8 @@ record PhysicalBoardPlan(
|
||||
static PhysicalBoardPlan create(BoardId anchor, BoardFacing facing) {
|
||||
Objects.requireNonNull(anchor, "anchor");
|
||||
Objects.requireNonNull(facing, "facing");
|
||||
List<Placement> placements = new ArrayList<>(23);
|
||||
Set<BoardId> interactions = new LinkedHashSet<>(15);
|
||||
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++) {
|
||||
@@ -37,12 +37,32 @@ record PhysicalBoardPlan(
|
||||
}
|
||||
for (int lateral = -1; lateral <= 1; lateral++) {
|
||||
BoardId location = offset(anchor, facing, lateral, 2, 1);
|
||||
placements.add(new Placement(location, "OAK_WALL_SIGN"));
|
||||
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
|
||||
) {
|
||||
@@ -54,14 +74,29 @@ record PhysicalBoardPlan(
|
||||
);
|
||||
}
|
||||
|
||||
record Placement(BoardId location, String material) {
|
||||
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 "OAK_WALL_SIGN".equals(material);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,15 @@ interface PhysicalBoardWorld {
|
||||
|
||||
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,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);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ record QuestBoardDialogSpec(
|
||||
Screen dashboard,
|
||||
Screen browse,
|
||||
Screen create,
|
||||
Screen createBounty,
|
||||
Screen claims,
|
||||
List<QuestEntry> questEntries
|
||||
) {
|
||||
@@ -78,10 +79,13 @@ record QuestBoardDialogSpec(
|
||||
enum Route {
|
||||
OPEN_BROWSE,
|
||||
OPEN_CREATE,
|
||||
OPEN_CREATE_BOUNTY,
|
||||
OPEN_CLAIMS,
|
||||
SUBMIT_CREATE,
|
||||
SUBMIT_BOUNTY,
|
||||
COMPLETE,
|
||||
CANCEL,
|
||||
CANCEL_BOUNTY,
|
||||
COLLECT
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,16 @@ final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
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);
|
||||
@@ -71,6 +81,20 @@ final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
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");
|
||||
@@ -80,14 +104,19 @@ final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
QuestBoardDialogSpec specification(Player player) {
|
||||
Objects.requireNonNull(player, "player");
|
||||
java.time.Instant now = clock.instant();
|
||||
List<QuestBoardDialogSpec.QuestEntry> entries = browser.activeQuests(now).stream()
|
||||
.map(quest -> questEntry(quest, player, now))
|
||||
.toList();
|
||||
List<QuestBoardDialogSpec.Action> dashboardActions = List.of(
|
||||
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),
|
||||
action("Pending claims", QuestBoardDialogSpec.Route.OPEN_CLAIMS)
|
||||
);
|
||||
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",
|
||||
@@ -101,7 +130,7 @@ final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
"Browse quests",
|
||||
entries.isEmpty()
|
||||
? "No active quests."
|
||||
: "Select a quest to view its request, reward, issuer, and time remaining.",
|
||||
: "Select a quest or bounty to view its objective, exact reward, issuer, and time remaining.",
|
||||
List.of(),
|
||||
List.of(),
|
||||
true
|
||||
@@ -113,23 +142,32 @@ final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
+ "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 block", "", 64),
|
||||
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
|
||||
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, claims, entries
|
||||
DIALOG_WIDTH, dashboard, browse, create, createBounty, claims, entries
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,12 +177,12 @@ final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
java.time.Instant now
|
||||
) {
|
||||
List<QuestBoardDialogSpec.Action> actions = new java.util.ArrayList<>();
|
||||
if (completer != null) {
|
||||
if (completer != null || this.actions != null) {
|
||||
actions.add(new QuestBoardDialogSpec.Action(
|
||||
"Complete quest", QuestBoardDialogSpec.Route.COMPLETE, quest.id().toString()
|
||||
));
|
||||
}
|
||||
if (canceller != null && quest.issuerId().equals(player.getUniqueId())) {
|
||||
if ((canceller != null || this.actions != null) && quest.issuerId().equals(player.getUniqueId())) {
|
||||
actions.add(new QuestBoardDialogSpec.Action(
|
||||
"Cancel quest", QuestBoardDialogSpec.Route.CANCEL, quest.id().toString()
|
||||
));
|
||||
@@ -157,6 +195,31 @@ final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
@@ -165,11 +228,11 @@ final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
}
|
||||
|
||||
private Dialog renderDashboard(QuestBoardDialogSpec specification, Player player) {
|
||||
List<Dialog> screens = List.of(
|
||||
renderBrowse(specification, player),
|
||||
renderCreate(specification, player),
|
||||
renderClaims(specification, 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(
|
||||
@@ -198,15 +261,9 @@ final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
ActionButton create = ActionButton.builder(Component.text("Create quest"))
|
||||
.tooltip(Component.text("Escrow your held stack and publish this quest"))
|
||||
.width(180)
|
||||
.action(DialogAction.customClick((response, audience) -> {
|
||||
if (audience instanceof Player respondingPlayer) {
|
||||
submit(
|
||||
respondingPlayer,
|
||||
response.getText("requested_material"),
|
||||
response.getText("requested_quantity")
|
||||
);
|
||||
}
|
||||
}, CALLBACK_OPTIONS))
|
||||
.action(nativeAction(player, (response, respondingPlayer) -> submit(
|
||||
respondingPlayer, response.getText("requested_material"), response.getText("requested_quantity")
|
||||
)))
|
||||
.build();
|
||||
return dialog(
|
||||
specification.create(),
|
||||
@@ -214,14 +271,25 @@ final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
if (claimant == null && actions == null) {
|
||||
return dialog(specification.claims(), DialogType.notice(back));
|
||||
}
|
||||
ActionButton collect = callbackButton(
|
||||
ActionButton collect = callbackButton(player,
|
||||
"Collect pending claims",
|
||||
"Collect delivered blocks and returned rewards",
|
||||
"Collect earned bounty rewards, delivered items and returned rewards",
|
||||
250,
|
||||
respondingPlayer -> submitClaim(respondingPlayer)
|
||||
);
|
||||
@@ -236,7 +304,7 @@ final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
);
|
||||
ActionButton back = backToBrowse(player);
|
||||
List<ActionButton> actions = entry.actions().stream()
|
||||
.map(action -> renderQuestAction(action))
|
||||
.map(action -> renderQuestAction(action, player))
|
||||
.toList();
|
||||
return dialog(
|
||||
screen,
|
||||
@@ -244,13 +312,17 @@ final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
);
|
||||
}
|
||||
|
||||
private ActionButton renderQuestAction(QuestBoardDialogSpec.Action action) {
|
||||
private ActionButton renderQuestAction(QuestBoardDialogSpec.Action action, Player original) {
|
||||
return switch (action.route()) {
|
||||
case COMPLETE -> callbackButton(
|
||||
action.label(), "Deliver the requested blocks", 180,
|
||||
case COMPLETE -> callbackButton(original,
|
||||
action.label(), "Deliver the requested items", 180,
|
||||
player -> submitCompletion(player, action.questId())
|
||||
);
|
||||
case CANCEL -> callbackButton(
|
||||
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())
|
||||
);
|
||||
@@ -278,7 +350,7 @@ final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
}
|
||||
|
||||
private ActionButton backToDashboard(Player player) {
|
||||
return callbackButton(
|
||||
return callbackButton(player,
|
||||
"Back", "Return to the quest-board dashboard", 120,
|
||||
respondingPlayer -> respondingPlayer.showDialog(
|
||||
renderDashboard(specification(respondingPlayer), respondingPlayer)
|
||||
@@ -287,7 +359,7 @@ final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
}
|
||||
|
||||
private ActionButton backToBrowse(Player player) {
|
||||
return callbackButton(
|
||||
return callbackButton(player,
|
||||
"Back", "Return to active quests", 120,
|
||||
respondingPlayer -> {
|
||||
QuestBoardDialogSpec current = specification(respondingPlayer);
|
||||
@@ -297,6 +369,7 @@ final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
}
|
||||
|
||||
private ActionButton callbackButton(
|
||||
Player original,
|
||||
String label,
|
||||
String tooltip,
|
||||
int width,
|
||||
@@ -305,36 +378,91 @@ final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
return ActionButton.builder(Component.text(label))
|
||||
.tooltip(Component.text(tooltip))
|
||||
.width(width)
|
||||
.action(DialogAction.customClick((response, audience) -> {
|
||||
if (audience instanceof Player respondingPlayer) {
|
||||
callback.accept(respondingPlayer);
|
||||
}
|
||||
}, CALLBACK_OPTIONS))
|
||||
.action(nativeAction(original, (response, respondingPlayer) -> callback.accept(respondingPlayer)))
|
||||
.build();
|
||||
}
|
||||
|
||||
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() {
|
||||
java.time.Instant now = clock.instant();
|
||||
return QuestListingFormatter.formatAll(browser.activeQuests(now), now);
|
||||
}
|
||||
|
||||
String claimListingText(Player player) {
|
||||
if (claimant == null) {
|
||||
if (claimant == null && service == null) {
|
||||
return "Claim collection is unavailable.";
|
||||
}
|
||||
List<QuestClaim> claims = claimant.pendingClaims(player);
|
||||
if (claims.isEmpty()) {
|
||||
return "No pending claims.";
|
||||
}
|
||||
return claims.stream().map(claim -> {
|
||||
String kind = claim.type() == QuestClaimType.DELIVERED_BLOCKS
|
||||
? "DELIVERED BLOCKS" : "RETURNED REWARD (" + claim.source().name() + ")";
|
||||
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 -> item.amount() + " " + item.material())
|
||||
.map(item -> describeItem(item, " "))
|
||||
.reduce((left, right) -> left + ", " + right)
|
||||
.orElseThrow();
|
||||
return kind + " — " + items + " — quest " + claim.questId();
|
||||
}).reduce((left, right) -> left + "\n" + 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) {
|
||||
@@ -342,6 +470,7 @@ final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -358,6 +487,7 @@ final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
}
|
||||
|
||||
void submitClaim(Player player) {
|
||||
if (actions != null) { actions.collect(player); return; }
|
||||
if (claimant == null) {
|
||||
player.sendMessage("Quest claims are unavailable.");
|
||||
return;
|
||||
@@ -372,6 +502,7 @@ final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -382,7 +513,7 @@ final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
player.sendMessage(exception.getMessage());
|
||||
} catch (IOException exception) {
|
||||
player.sendMessage(
|
||||
"The quest could not be saved. Your delivered blocks were restored."
|
||||
"The quest could not be saved. Your delivered items were restored."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -395,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.");
|
||||
|
||||
@@ -32,7 +32,10 @@ record QuestClaim(
|
||||
}
|
||||
|
||||
QuestClaimType type() {
|
||||
return source == QuestClaimSource.COMPLETION
|
||||
? QuestClaimType.DELIVERED_BLOCKS : QuestClaimType.RETURNED_REWARD;
|
||||
return switch (source) {
|
||||
case COMPLETION -> QuestClaimType.DELIVERED_BLOCKS;
|
||||
case BOUNTY -> QuestClaimType.EARNED_BOUNTY_REWARD;
|
||||
case CANCELLATION, EXPIRATION -> QuestClaimType.RETURNED_REWARD;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ final class QuestClaimController implements QuestClaimGateway {
|
||||
|
||||
private static String description(QuestClaim claim) {
|
||||
if (claim.type() == QuestClaimType.DELIVERED_BLOCKS) {
|
||||
return "Collected delivered blocks from completed quest " + claim.questId() + ".";
|
||||
return "Collected delivered items from completed quest " + claim.questId() + ".";
|
||||
}
|
||||
String reason = claim.source() == QuestClaimSource.EXPIRATION
|
||||
? "expired" : "cancelled";
|
||||
|
||||
@@ -3,5 +3,6 @@ package games.dmg.spigotquestboard;
|
||||
enum QuestClaimSource {
|
||||
COMPLETION,
|
||||
CANCELLATION,
|
||||
EXPIRATION
|
||||
EXPIRATION,
|
||||
BOUNTY
|
||||
}
|
||||
@@ -2,5 +2,6 @@ package games.dmg.spigotquestboard;
|
||||
|
||||
enum QuestClaimType {
|
||||
DELIVERED_BLOCKS,
|
||||
EARNED_BOUNTY_REWARD,
|
||||
RETURNED_REWARD
|
||||
}
|
||||
@@ -14,6 +14,8 @@ 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;
|
||||
@@ -21,6 +23,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
|
||||
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);
|
||||
@@ -53,7 +56,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
|
||||
QuestClaimGateway claimant,
|
||||
Clock clock
|
||||
) {
|
||||
this(creator, browser, completer, canceller, claimant, clock, null);
|
||||
this(creator, browser, completer, canceller, claimant, clock, null, null);
|
||||
}
|
||||
|
||||
QuestCommand(
|
||||
@@ -64,6 +67,19 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
|
||||
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");
|
||||
@@ -72,6 +88,20 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
|
||||
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
|
||||
@@ -79,10 +109,19 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
|
||||
CommandSender sender, Command command, String label, String[] arguments
|
||||
) {
|
||||
if (!playerCommandsEnabled()) {
|
||||
sender.sendMessage(
|
||||
"Player quest commands are disabled. Use a physical quest board instead."
|
||||
);
|
||||
return true;
|
||||
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]))) {
|
||||
@@ -95,6 +134,10 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
|
||||
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;
|
||||
@@ -113,6 +156,10 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
|
||||
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;
|
||||
@@ -123,7 +170,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
|
||||
sender.sendMessage(exception.getMessage());
|
||||
} catch (IOException exception) {
|
||||
sender.sendMessage(
|
||||
"The quest could not be saved. Your delivered blocks were restored."
|
||||
"The quest could not be saved. Your delivered items were restored."
|
||||
);
|
||||
}
|
||||
return true;
|
||||
@@ -133,6 +180,10 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
|
||||
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;
|
||||
@@ -163,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.");
|
||||
@@ -178,16 +233,27 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
|
||||
public List<String> onTabComplete(
|
||||
CommandSender sender, Command command, String alias, String[] arguments
|
||||
) {
|
||||
if (!playerCommandsEnabled() || !(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", "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]);
|
||||
@@ -196,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]
|
||||
);
|
||||
@@ -208,6 +273,21 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
|
||||
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()
|
||||
@@ -217,7 +297,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
|
||||
|
||||
private static void usage(CommandSender sender) {
|
||||
sender.sendMessage(
|
||||
"Usage: /quests [list] | /quests create <block> <quantity> | "
|
||||
"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.");
|
||||
|
||||
@@ -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,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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,18 @@ 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.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 QuestState state;
|
||||
// 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");
|
||||
@@ -33,7 +36,7 @@ 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,
|
||||
@@ -41,11 +44,114 @@ final class QuestService implements QuestBrowser {
|
||||
);
|
||||
Map<UUID, Quest> quests = new LinkedHashMap<>(state.quests());
|
||||
quests.put(quest.id(), quest);
|
||||
save(new QuestState(quests, state.claims(), state.notifications()));
|
||||
save(new QuestState(quests, state.claims(), state.notifications(), state.bounties()));
|
||||
return quest;
|
||||
}
|
||||
|
||||
synchronized Quest completionCandidate(UUID questId, 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 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);
|
||||
@@ -66,6 +172,20 @@ final class QuestService implements QuestBrowser {
|
||||
|
||||
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"));
|
||||
@@ -90,7 +210,7 @@ final class QuestService implements QuestBrowser {
|
||||
IssuerNotification notification = new IssuerNotification(
|
||||
UUID.randomUUID(), active.id(), active.issuerId(),
|
||||
"Quest " + active.id() + " was completed. Your delivered "
|
||||
+ active.requestedMaterial() + " blocks can be claimed at a quest board.",
|
||||
+ active.requestedMaterial() + " items can be claimed at a quest board.",
|
||||
completedAt
|
||||
);
|
||||
|
||||
@@ -104,7 +224,13 @@ final class QuestService implements QuestBrowser {
|
||||
claims.put(active.issuerId(), ownerClaims);
|
||||
Map<UUID, IssuerNotification> notifications = new LinkedHashMap<>(state.notifications());
|
||||
notifications.put(notification.id(), notification);
|
||||
save(new QuestState(quests, claims, notifications));
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -141,7 +267,7 @@ final class QuestService implements QuestBrowser {
|
||||
);
|
||||
ownerClaims.add(claim);
|
||||
claims.put(active.issuerId(), ownerClaims);
|
||||
save(new QuestState(quests, claims, state.notifications()));
|
||||
save(new QuestState(quests, claims, state.notifications(), state.bounties()));
|
||||
return claim;
|
||||
}
|
||||
|
||||
@@ -176,13 +302,32 @@ final class QuestService implements QuestBrowser {
|
||||
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));
|
||||
save(new QuestState(quests, claims, notifications, bounties));
|
||||
}
|
||||
return List.copyOf(expiredClaims);
|
||||
}
|
||||
|
||||
synchronized List<QuestClaim> claimsFor(UUID ownerId) {
|
||||
List<QuestClaim> claimsFor(UUID ownerId) {
|
||||
Objects.requireNonNull(ownerId, "ownerId");
|
||||
return state.claims().getOrDefault(ownerId, List.of());
|
||||
}
|
||||
@@ -190,6 +335,9 @@ final class QuestService implements QuestBrowser {
|
||||
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;
|
||||
@@ -202,10 +350,10 @@ final class QuestService implements QuestBrowser {
|
||||
} else {
|
||||
claims.put(ownerId, remaining);
|
||||
}
|
||||
save(new QuestState(state.quests(), claims, state.notifications()));
|
||||
save(new QuestState(state.quests(), claims, state.notifications(), state.bounties()));
|
||||
}
|
||||
|
||||
synchronized List<IssuerNotification> pendingNotifications(UUID recipientId) {
|
||||
List<IssuerNotification> pendingNotifications(UUID recipientId) {
|
||||
Objects.requireNonNull(recipientId, "recipientId");
|
||||
return state.notifications().values().stream()
|
||||
.filter(notification -> notification.recipientId().equals(recipientId))
|
||||
@@ -218,11 +366,11 @@ final class QuestService implements QuestBrowser {
|
||||
}
|
||||
Map<UUID, IssuerNotification> notifications = new LinkedHashMap<>(state.notifications());
|
||||
notifications.remove(notificationId);
|
||||
save(new QuestState(state.quests(), state.claims(), notifications));
|
||||
save(new QuestState(state.quests(), state.claims(), notifications, state.bounties()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized List<Quest> activeQuests(Instant now) {
|
||||
public List<Quest> activeQuests(Instant now) {
|
||||
Objects.requireNonNull(now, "now");
|
||||
return state.quests().values().stream()
|
||||
.filter(quest -> quest.status() == QuestStatus.ACTIVE)
|
||||
@@ -230,10 +378,18 @@ final class QuestService implements QuestBrowser {
|
||||
.toList();
|
||||
}
|
||||
|
||||
synchronized QuestState state() {
|
||||
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
|
||||
) {
|
||||
@@ -243,7 +399,85 @@ final class QuestService implements QuestBrowser {
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,17 @@ import java.util.UUID;
|
||||
record QuestState(
|
||||
Map<UUID, Quest> quests,
|
||||
Map<UUID, List<QuestClaim>> claims,
|
||||
Map<UUID, IssuerNotification> notifications
|
||||
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())) {
|
||||
@@ -28,6 +36,25 @@ record QuestState(
|
||||
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())) {
|
||||
@@ -36,6 +63,20 @@ record QuestState(
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -1,94 +1,212 @@
|
||||
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;
|
||||
final PlayerCommandSettings playerCommands;
|
||||
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")
|
||||
));
|
||||
playerCommands = new PlayerCommandSettings(
|
||||
new YamlPlayerCommandSettingsRepository(
|
||||
getDataFolder().toPath().resolve("settings.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 plugin state", exception);
|
||||
getLogger().log(Level.SEVERE, "Could not upgrade physical boards", exception);
|
||||
getServer().getPluginManager().disablePlugin(this);
|
||||
return;
|
||||
}
|
||||
|
||||
QuestCreationGateway creator = new QuestCreationController(
|
||||
quests, new BukkitBlockMaterialCatalog(), new BukkitHeldRewardInventory()
|
||||
);
|
||||
Clock clock = Clock.systemUTC();
|
||||
BukkitIssuerNotifier notifier = new BukkitIssuerNotifier(
|
||||
quests, getServer(), getLogger()
|
||||
);
|
||||
QuestCompletionGateway completer = new QuestCompletionController(
|
||||
quests, new BukkitQuestCompletionInventory(), notifier
|
||||
);
|
||||
QuestCancellationGateway canceller = new QuestCancellationController(quests);
|
||||
QuestClaimGateway claimant = new QuestClaimController(
|
||||
quests, new BukkitQuestClaimInventory()
|
||||
);
|
||||
CommandHandlers handlers = commandHandlers(
|
||||
boards, playerCommands, creator, quests, completer, canceller, claimant, clock
|
||||
);
|
||||
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(notifier, this);
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new QuestBoardInteractionListener(
|
||||
boards, new QuestBoardDialogUi(
|
||||
creator, quests, completer, canceller, claimant, clock
|
||||
)
|
||||
),
|
||||
this
|
||||
);
|
||||
getServer().getScheduler().runTaskTimer(
|
||||
this,
|
||||
new QuestExpiryTask(quests, notifier, clock, getLogger()),
|
||||
0L,
|
||||
20L
|
||||
);
|
||||
getLogger().info(
|
||||
"Spigot Quest Board enabled with " + boards.size() + " boards and "
|
||||
+ quests.state().quests().size() + " 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
|
||||
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
|
||||
)
|
||||
);
|
||||
return new CommandHandlers(new QuestAdminCommand(boards, playerCommands),
|
||||
new QuestCommand(creator, browser, completer, canceller, claimant, clock, playerCommands, boards));
|
||||
}
|
||||
|
||||
record CommandHandlers(QuestAdminCommand admin, QuestCommand quests) { }
|
||||
|
||||
@@ -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,10 +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(
|
||||
@@ -54,6 +81,27 @@ 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")),
|
||||
@@ -84,7 +132,11 @@ final class YamlQuestRepository implements QuestRepository {
|
||||
} catch (IllegalArgumentException | DateTimeParseException exception) {
|
||||
throw new IOException("Invalid quest record", exception);
|
||||
}
|
||||
return new QuestState(quests, claims, notifications);
|
||||
try {
|
||||
return new QuestState(quests, claims, notifications, bounties, transfers);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IOException("Invalid transfer journal", exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -106,6 +158,30 @@ final class YamlQuestRepository implements QuestRepository {
|
||||
}
|
||||
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) {
|
||||
@@ -132,6 +208,19 @@ final class YamlQuestRepository implements QuestRepository {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -152,7 +241,53 @@ final class YamlQuestRepository implements QuestRepository {
|
||||
}
|
||||
}
|
||||
|
||||
private static List<EscrowItem> readItems(Object value, String description) {
|
||||
private static List<Map<?, ?>> readBountyEntries(Object value) {
|
||||
if (value == null) {
|
||||
return List.of(); // Files predating bounties have no such section.
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -165,9 +300,22 @@ final class YamlQuestRepository implements QuestRepository {
|
||||
if (data != null && !(data instanceof String)) {
|
||||
throw new IllegalArgumentException("Invalid item-data");
|
||||
}
|
||||
items.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 items;
|
||||
}
|
||||
@@ -181,6 +329,9 @@ final class YamlQuestRepository implements QuestRepository {
|
||||
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;
|
||||
@@ -220,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;
|
||||
}
|
||||
}
|
||||
@@ -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"; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,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(); } }
|
||||
}
|
||||
@@ -4,15 +4,21 @@ 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 {
|
||||
@@ -82,6 +88,90 @@ final class BukkitIssuerNotifierTest {
|
||||
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(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
@@ -8,6 +9,7 @@ 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;
|
||||
@@ -56,6 +58,104 @@ final class BukkitPhysicalBoardWorldTest {
|
||||
));
|
||||
}
|
||||
|
||||
@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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -33,14 +33,18 @@ final class PhysicalBoardCreatorTest {
|
||||
creator.create(anchorBlock(), BoardFacing.NORTH)
|
||||
);
|
||||
|
||||
assertEquals(23, world.placed.size());
|
||||
assertEquals(25, world.placed.size());
|
||||
assertEquals("AIR", world.materials.getOrDefault(ANCHOR, "AIR"));
|
||||
assertEquals(15, registry.size());
|
||||
assertEquals(15, repository.state.boards().size());
|
||||
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
|
||||
@@ -49,7 +53,9 @@ final class PhysicalBoardCreatorTest {
|
||||
BoardRegistry registry = new BoardRegistry(repository);
|
||||
FakeWorld world = new FakeWorld();
|
||||
BoardId obstruction = PhysicalBoardPlan.create(ANCHOR, BoardFacing.EAST)
|
||||
.placements().get(7).location();
|
||||
.placements().stream()
|
||||
.filter(placement -> placement.signKind() == PhysicalBoardSignKind.TITLE)
|
||||
.findFirst().orElseThrow().location();
|
||||
world.materials.put(obstruction, "STONE");
|
||||
|
||||
assertEquals(
|
||||
|
||||
@@ -19,9 +19,9 @@ final class PhysicalBoardPlanTest {
|
||||
.filter(block -> block.material().equals("OAK_LOG")).count());
|
||||
assertEquals(12, plan.placements().stream()
|
||||
.filter(block -> block.material().equals("OAK_PLANKS")).count());
|
||||
assertEquals(3, plan.placements().stream()
|
||||
assertEquals(5, plan.placements().stream()
|
||||
.filter(block -> block.material().equals("OAK_WALL_SIGN")).count());
|
||||
assertEquals(15, plan.interactionLocations().size());
|
||||
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());
|
||||
@@ -35,8 +35,8 @@ final class PhysicalBoardPlanTest {
|
||||
|
||||
for (BoardFacing facing : BoardFacing.values()) {
|
||||
PhysicalBoardPlan plan = PhysicalBoardPlan.create(anchor, facing);
|
||||
assertEquals(23, plan.placements().size());
|
||||
assertEquals(23, new HashSet<>(plan.placements().stream()
|
||||
assertEquals(25, plan.placements().size());
|
||||
assertEquals(25, new HashSet<>(plan.placements().stream()
|
||||
.map(PhysicalBoardPlan.Placement::location).toList()).size());
|
||||
|
||||
var backing = plan.placements().stream().filter(
|
||||
@@ -53,13 +53,20 @@ final class PhysicalBoardPlanTest {
|
||||
var signs = plan.placements().stream().filter(
|
||||
PhysicalBoardPlan.Placement::sign
|
||||
).toList();
|
||||
assertEquals(3, signs.size());
|
||||
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() + 2
|
||||
&& sign.location().y() <= anchor.y() + 4
|
||||
));
|
||||
assertEquals(15, plan.interactionLocations().size());
|
||||
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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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;
|
||||
@@ -8,12 +9,91 @@ 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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,8 +206,8 @@ final class QuestAdminCommandTest {
|
||||
new String[] {"createboard", "physical"}
|
||||
));
|
||||
|
||||
assertEquals(23, placements.get());
|
||||
assertEquals(15, repository.state.boards().size());
|
||||
assertEquals(25, placements.get());
|
||||
assertEquals(17, repository.state.boards().size());
|
||||
verify(player).sendMessage("Physical quest board created.");
|
||||
}
|
||||
|
||||
|
||||
@@ -140,6 +140,18 @@ final class QuestBoardDialogUiTest {
|
||||
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);
|
||||
@@ -267,7 +279,7 @@ final class QuestBoardDialogUiTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void boardListingDistinguishesDeliveredBlocksFromReturnedRewards() {
|
||||
void boardListingDistinguishesDeliveredItemsFromReturnedRewards() {
|
||||
RecordingCreator creator = new RecordingCreator(false);
|
||||
Player player = mock(Player.class);
|
||||
UUID owner = UUID.randomUUID();
|
||||
@@ -301,7 +313,7 @@ final class QuestBoardDialogUiTest {
|
||||
|
||||
String listing = ui.claimListingText(player);
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertTrue(listing.contains("DELIVERED BLOCKS — 2 STONE"));
|
||||
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")
|
||||
);
|
||||
@@ -412,7 +424,7 @@ final class QuestBoardDialogUiTest {
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> suggestBlockMaterials(String prefix) {
|
||||
public List<String> suggestItemMaterials(String prefix) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ final class QuestClaimControllerTest {
|
||||
|
||||
assertEquals(new ClaimCollectionResult(3, 6), result);
|
||||
assertTrue(service.claimsFor(issuer).isEmpty());
|
||||
verify(player).sendMessage(contains("delivered blocks from completed quest"));
|
||||
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"));
|
||||
|
||||
@@ -14,15 +14,19 @@ 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 disabledSettingGatesEveryPlayerCommandFormAndAutocomplete() throws Exception {
|
||||
void disabledSettingGatesNonCreationCommandForms() throws Exception {
|
||||
QuestCreationGateway creator = mock(QuestCreationGateway.class);
|
||||
QuestBrowser browser = mock(QuestBrowser.class);
|
||||
QuestCompletionGateway completer = mock(QuestCompletionGateway.class);
|
||||
@@ -43,7 +47,6 @@ final class QuestCommandTest {
|
||||
List<String[]> forms = List.of(
|
||||
new String[] {},
|
||||
new String[] {"list"},
|
||||
new String[] {"create", "stone", "1"},
|
||||
new String[] {"complete", "quest-id"},
|
||||
new String[] {"cancel", "quest-id"},
|
||||
new String[] {"claim"}
|
||||
@@ -62,6 +65,97 @@ final class QuestCommandTest {
|
||||
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() {
|
||||
RecordingCreator creator = new RecordingCreator();
|
||||
@@ -238,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());
|
||||
}
|
||||
@@ -306,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");
|
||||
}
|
||||
|
||||
@@ -71,9 +71,9 @@ final class QuestCreationControllerTest {
|
||||
private static QuestCreationController controller(
|
||||
MemoryQuestRepository repository, HeldRewardInventory inventory
|
||||
) throws IOException {
|
||||
BlockMaterialCatalog catalog = new BlockMaterialCatalog() {
|
||||
ItemMaterialCatalog catalog = new ItemMaterialCatalog() {
|
||||
@Override
|
||||
public Optional<String> normalizeBlock(String input) {
|
||||
public Optional<String> normalizeItem(String input) {
|
||||
return "stone".equalsIgnoreCase(input) ? Optional.of("STONE") : Optional.empty();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.command.PluginCommand;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.bukkit.scheduler.BukkitScheduler;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class QuestLifecycleRecoveryTest {
|
||||
private static final Instant AT = Instant.parse("2026-01-01T00:00:00Z");
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test void replacementCannotLoadStaleSnapshotWhileDisabledWriterStillDrains() throws Exception {
|
||||
Harness first = new Harness(directory);
|
||||
Harness replacement = new Harness(directory.resolve("."));
|
||||
Harness afterDrain = new Harness(directory);
|
||||
CountDownLatch entered = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
UUID issuer = UUID.randomUUID();
|
||||
try {
|
||||
first.enable();
|
||||
first.runtime.worker().execute(() -> {
|
||||
entered.countDown();
|
||||
await(release);
|
||||
try {
|
||||
first.service.get().createBounty(issuer, "Issuer", UUID.randomUUID(), "Target", 1,
|
||||
List.of(new EscrowItem("DIAMOND", 3, null)), AT);
|
||||
} catch (Exception exception) { throw new AssertionError(exception); }
|
||||
});
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
first.plugin.onDisable(); // Must return without joining the paused writer.
|
||||
replacement.enable();
|
||||
assertNull(replacement.service.get(), "replacement must fail closed, not load the pre-write snapshot");
|
||||
replacement.disable();
|
||||
release.countDown();
|
||||
assertTrue(first.worker.awaitTermination(5, TimeUnit.SECONDS));
|
||||
afterDrain.enable();
|
||||
assertEquals(1, afterDrain.service.get().state().bounties().size());
|
||||
afterDrain.runtime.worker().execute(() -> {
|
||||
try {
|
||||
afterDrain.service.get().createBounty(issuer, "Issuer", UUID.randomUUID(), "Second", 1,
|
||||
List.of(new EscrowItem("GOLD_INGOT", 2, null)), AT);
|
||||
} catch (Exception exception) { throw new AssertionError(exception); }
|
||||
});
|
||||
afterDrain.fence();
|
||||
assertEquals(2, new YamlQuestRepository(directory.resolve("quests.yml")).load().bounties().size());
|
||||
} finally {
|
||||
release.countDown();
|
||||
first.disable();
|
||||
replacement.disable();
|
||||
afterDrain.disable();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void samePluginInstanceCannotResetItsGenerationWhilePriorDrainIsOutstanding() throws Exception {
|
||||
Harness harness = new Harness(directory);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
try {
|
||||
harness.enable();
|
||||
harness.runtime.worker().execute(() -> await(release));
|
||||
harness.plugin.onDisable();
|
||||
assertDoesNotThrow(harness.plugin::onEnable, "unsafe hot re-enable must be rejected, not overwrite live generation fields");
|
||||
verify(harness.plugin, times(1)).createRuntime();
|
||||
} finally { release.countDown(); harness.disable(); }
|
||||
}
|
||||
|
||||
@Test void actualEnableHydratesSerializedYamlMetadataOnlyOnMain() throws Exception {
|
||||
seedSerializedRewards();
|
||||
Harness harness = new Harness(directory);
|
||||
org.bukkit.inventory.ItemStack stack = mock(org.bukkit.inventory.ItemStack.class);
|
||||
org.bukkit.inventory.meta.ItemMeta meta = mock(org.bukkit.inventory.meta.ItemMeta.class);
|
||||
when(stack.getType()).thenReturn(org.bukkit.Material.DIAMOND);
|
||||
when(stack.getAmount()).thenReturn(3);
|
||||
when(stack.hasItemMeta()).thenReturn(true);
|
||||
when(stack.getItemMeta()).thenReturn(meta);
|
||||
when(meta.hasCustomName()).thenReturn(true);
|
||||
when(meta.customName()).thenReturn(net.kyori.adventure.text.Component.text("Legacy exact reward"));
|
||||
try (var decoding = mockStatic(org.bukkit.inventory.ItemStack.class)) {
|
||||
decoding.when(() -> org.bukkit.inventory.ItemStack.deserializeBytes(any(byte[].class))).thenAnswer(call -> {
|
||||
assertSame(harness.mainThread, Thread.currentThread(), "Bukkit metadata decoding must run on tick");
|
||||
assertArrayEquals(new byte[]{0}, call.getArgument(0));
|
||||
return stack;
|
||||
});
|
||||
try {
|
||||
harness.plugin.onEnable();
|
||||
while (harness.service.get() == null) {
|
||||
Runnable task = harness.main.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(task, "startup must publish main decode/start callback");
|
||||
task.run();
|
||||
}
|
||||
harness.fence();
|
||||
harness.pumpMain();
|
||||
decoding.verify(() -> org.bukkit.inventory.ItemStack.deserializeBytes(any(byte[].class)), times(2));
|
||||
assertTrue(harness.service.get().state().claims().values().stream().flatMap(List::stream)
|
||||
.flatMap(claim -> claim.items().stream()).allMatch(item -> "Legacy exact reward".equals(item.customName())));
|
||||
} finally { harness.disable(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void disableWithFirstDecodeQueuedRejectsSuccessiveDecodeWithoutDeadlock() throws Exception {
|
||||
seedSerializedRewards();
|
||||
Harness harness = new Harness(directory);
|
||||
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 (var decoding = mockStatic(org.bukkit.inventory.ItemStack.class)) {
|
||||
decoding.when(() -> org.bukkit.inventory.ItemStack.deserializeBytes(any(byte[].class))).thenAnswer(call -> {
|
||||
assertSame(harness.mainThread, Thread.currentThread());
|
||||
return stack;
|
||||
});
|
||||
try {
|
||||
harness.plugin.onEnable();
|
||||
assertNotNull(harness.main.poll(5, TimeUnit.SECONDS), "first decode is queued but not yet executed");
|
||||
harness.disable(); // close() executes accepted first decode; the next decode is rejected.
|
||||
decoding.verify(() -> org.bukkit.inventory.ItemStack.deserializeBytes(any(byte[].class)), times(1));
|
||||
assertNull(harness.service.get(), "partially hydrated load must not start gameplay");
|
||||
assertTrue(harness.runtime.drainCompletion().toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
} finally { harness.disable(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void abortedDecodeRetainsCapturedDeathsAndFailsClosedInsteadOfDiscardingStartupBuffer() throws Exception {
|
||||
seedSerializedRewards();
|
||||
Harness harness = new Harness(directory);
|
||||
UUID target = UUID.randomUUID();
|
||||
UUID hunter = UUID.randomUUID();
|
||||
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 (var decoding = mockStatic(org.bukkit.inventory.ItemStack.class)) {
|
||||
decoding.when(() -> org.bukkit.inventory.ItemStack.deserializeBytes(any(byte[].class))).thenReturn(stack);
|
||||
try {
|
||||
harness.plugin.onEnable();
|
||||
assertNotNull(harness.main.poll(5, TimeUnit.SECONDS));
|
||||
deathListener(harness).onDeath(death(target, hunter));
|
||||
harness.disable();
|
||||
assertFalse(harness.runtime.drainCompletion().toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
verify(harness.logger).severe(argThat((String message) -> message.contains("INCOMPLETE")
|
||||
&& message.contains(target.toString()) && message.contains(hunter.toString()) && message.contains(AT.toString())));
|
||||
Harness replacement = new Harness(directory);
|
||||
try {
|
||||
replacement.enable();
|
||||
assertNull(replacement.service.get(), "unreplayed startup death keeps the earlier owner");
|
||||
} finally { replacement.disable(); }
|
||||
} finally { harness.disable(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void startupCapturesTwoHuntersAndReplaysOriginalDeathBeforeExpiryAndReadyEvents() throws Exception {
|
||||
UUID issuer = UUID.randomUUID();
|
||||
UUID target = UUID.randomUUID();
|
||||
UUID firstHunter = UUID.randomUUID();
|
||||
UUID laterHunter = UUID.randomUUID();
|
||||
QuestService seed = new QuestService(new YamlQuestRepository(directory.resolve("quests.yml")));
|
||||
seed.createBounty(issuer, "Issuer", target, "Target", 1, List.of(new EscrowItem("DIAMOND", 3, null)), AT);
|
||||
Harness harness = new Harness(directory);
|
||||
CountDownLatch releaseLoad = new CountDownLatch(1);
|
||||
AtomicReference<Instant> now = new AtomicReference<>(AT.plusSeconds(1));
|
||||
Clock clock = mock(Clock.class);
|
||||
when(clock.instant()).thenAnswer(call -> now.get());
|
||||
doReturn(clock).when(harness.plugin).runtimeClock();
|
||||
when(harness.bukkit.runTaskTimer(eq(harness.plugin), any(Runnable.class), eq(0L), eq(20L)))
|
||||
.thenAnswer(call -> { ((Runnable) call.getArgument(1)).run(); return null; });
|
||||
harness.worker.execute(() -> await(releaseLoad));
|
||||
try {
|
||||
harness.plugin.onEnable();
|
||||
BountyDeathListener listener = deathListener(harness);
|
||||
var first = death(target, firstHunter);
|
||||
listener.onDeath(first);
|
||||
now.set(AT.plusSeconds(2));
|
||||
listener.onDeath(death(target, laterHunter));
|
||||
when(first.getEntity().getKiller().getUniqueId()).thenReturn(laterHunter);
|
||||
listener.onDeath(first); // Same native object must retain original immutable identity/attribution.
|
||||
now.set(AT.plusSeconds(7 * 86400L + 1));
|
||||
releaseLoad.countDown();
|
||||
harness.fence();
|
||||
harness.pumpMain();
|
||||
listener.onDeath(death(target, laterHunter));
|
||||
harness.fence();
|
||||
harness.pumpMain();
|
||||
QuestService reloaded = new QuestService(new YamlQuestRepository(directory.resolve("quests.yml")));
|
||||
assertEquals(1, reloaded.claimsFor(firstHunter).size());
|
||||
assertEquals(AT.plusSeconds(1), reloaded.claimsFor(firstHunter).getFirst().createdAt());
|
||||
assertTrue(reloaded.claimsFor(laterHunter).isEmpty());
|
||||
assertTrue(reloaded.claimsFor(issuer).isEmpty(), "expiry must not overtake captured first winner");
|
||||
} finally { releaseLoad.countDown(); harness.disable(); }
|
||||
}
|
||||
|
||||
@Test void disableDuringLoadStillPersistsCapturedDeathBeforeReleasingOwnership() throws Exception {
|
||||
UUID target = UUID.randomUUID();
|
||||
UUID hunter = UUID.randomUUID();
|
||||
QuestService seed = new QuestService(new YamlQuestRepository(directory.resolve("quests.yml")));
|
||||
seed.createBounty(UUID.randomUUID(), "Issuer", target, "Target", 1,
|
||||
List.of(new EscrowItem("DIAMOND", 3, null)), AT.minusSeconds(1));
|
||||
Harness harness = new Harness(directory);
|
||||
CountDownLatch releaseLoad = new CountDownLatch(1);
|
||||
harness.worker.execute(() -> await(releaseLoad));
|
||||
try {
|
||||
harness.plugin.onEnable();
|
||||
deathListener(harness).onDeath(death(target, hunter));
|
||||
harness.plugin.onDisable();
|
||||
releaseLoad.countDown();
|
||||
assertTrue(harness.worker.awaitTermination(5, TimeUnit.SECONDS));
|
||||
assertTrue(harness.runtime.drainCompletion().toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals(1, new QuestService(new YamlQuestRepository(directory.resolve("quests.yml"))).claimsFor(hunter).size());
|
||||
} finally { releaseLoad.countDown(); harness.disable(); }
|
||||
}
|
||||
|
||||
private static BountyDeathListener deathListener(Harness harness) {
|
||||
var listeners = org.mockito.ArgumentCaptor.forClass(org.bukkit.event.Listener.class);
|
||||
verify(harness.manager, atLeastOnce()).registerEvents(listeners.capture(), eq(harness.plugin));
|
||||
return listeners.getAllValues().stream().filter(BountyDeathListener.class::isInstance)
|
||||
.map(BountyDeathListener.class::cast).findFirst().orElseThrow();
|
||||
}
|
||||
|
||||
private static org.bukkit.event.entity.PlayerDeathEvent death(UUID targetId, UUID hunterId) {
|
||||
org.bukkit.entity.Player target = mock(org.bukkit.entity.Player.class);
|
||||
org.bukkit.entity.Player hunter = mock(org.bukkit.entity.Player.class);
|
||||
when(target.getUniqueId()).thenReturn(targetId);
|
||||
when(hunter.getUniqueId()).thenReturn(hunterId);
|
||||
when(target.getKiller()).thenReturn(hunter);
|
||||
var event = mock(org.bukkit.event.entity.PlayerDeathEvent.class);
|
||||
when(event.getEntity()).thenReturn(target);
|
||||
return event;
|
||||
}
|
||||
|
||||
private void seedSerializedRewards() throws Exception {
|
||||
UUID owner = UUID.randomUUID();
|
||||
QuestClaim claim = new QuestClaim(UUID.randomUUID(), UUID.randomUUID(), owner,
|
||||
List.of(new EscrowItem("DIAMOND", 3, "AA=="), new EscrowItem("DIAMOND", 3, "AA==")), AT, QuestClaimSource.BOUNTY);
|
||||
new YamlQuestRepository(directory.resolve("quests.yml")).save(new QuestState(java.util.Map.of(),
|
||||
java.util.Map.of(owner, List.of(claim)), java.util.Map.of()));
|
||||
}
|
||||
|
||||
static void await(CountDownLatch latch) {
|
||||
try { assertTrue(latch.await(5, TimeUnit.SECONDS), "latch timed out"); }
|
||||
catch (InterruptedException exception) { Thread.currentThread().interrupt(); throw new AssertionError(exception); }
|
||||
}
|
||||
|
||||
static final class Harness {
|
||||
final Thread mainThread = Thread.currentThread();
|
||||
final ExecutorService worker = Executors.newSingleThreadExecutor(Thread.ofPlatform().name("lifecycle-test-io").factory());
|
||||
final BlockingQueue<Runnable> main = new LinkedBlockingQueue<>();
|
||||
final QuestRuntimeScheduler runtime = new QuestRuntimeScheduler(worker, main::add, () -> Thread.currentThread() == mainThread);
|
||||
final SpigotQuestBoardPlugin plugin = mock(SpigotQuestBoardPlugin.class, CALLS_REAL_METHODS);
|
||||
final Server server = mock(Server.class);
|
||||
final PluginManager manager = mock(PluginManager.class);
|
||||
final BukkitScheduler bukkit = mock(BukkitScheduler.class);
|
||||
final Logger logger = mock(Logger.class);
|
||||
final AtomicReference<QuestService> service = new AtomicReference<>();
|
||||
Harness(Path directory) {
|
||||
doReturn(server).when(plugin).getServer();
|
||||
doReturn(directory.toFile()).when(plugin).getDataFolder();
|
||||
doReturn(logger).when(plugin).getLogger();
|
||||
doReturn(runtime).when(plugin).createRuntime();
|
||||
doReturn(Clock.fixed(AT, ZoneOffset.UTC)).when(plugin).runtimeClock();
|
||||
doReturn(mock(PluginCommand.class)).when(plugin).getCommand(anyString());
|
||||
doAnswer(call -> {
|
||||
QuestService loaded = call.getArgument(0);
|
||||
service.set(loaded);
|
||||
return new AsyncQuestActions(loaded, runtime.worker(), runtime.main(), logger);
|
||||
}).when(plugin).runtimeActions(any(), eq(runtime));
|
||||
when(server.getPluginManager()).thenReturn(manager);
|
||||
when(server.getScheduler()).thenReturn(bukkit);
|
||||
}
|
||||
void fence() throws Exception { worker.submit(() -> { }).get(5, TimeUnit.SECONDS); }
|
||||
void enable() throws Exception {
|
||||
plugin.onEnable();
|
||||
fence();
|
||||
pumpMain();
|
||||
fence();
|
||||
pumpMain();
|
||||
}
|
||||
void pumpMain() { for (Runnable task; (task = main.poll()) != null;) { task.run(); } }
|
||||
void disable() throws Exception {
|
||||
plugin.onDisable();
|
||||
if (!worker.isShutdown()) { worker.shutdown(); }
|
||||
assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS), "disable must not strand worker waiting for main");
|
||||
pumpMain();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,19 @@ final class QuestListingFormatterTest {
|
||||
assertTrue(listing.contains("Time remaining: 1d 1h 1m 1s"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void showsCustomRewardNameTogetherWithMaterialType() {
|
||||
Quest quest = quest(
|
||||
NOW.plusSeconds(60),
|
||||
List.of(new EscrowItem("PAPER", 1, "opaque-exact-data", "Shopping List"))
|
||||
);
|
||||
|
||||
String listing = QuestListingFormatter.format(quest, NOW);
|
||||
|
||||
assertTrue(listing.contains("1 × Shopping List (PAPER)"));
|
||||
assertFalse(listing.contains("PAPER (with exact item data)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundsAnActiveSubsecondBoundaryUpToOneSecond() {
|
||||
Quest quest = quest(NOW.plusNanos(1), List.of(new EscrowItem("DIAMOND", 1, null)));
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.net.URLClassLoader;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class QuestPersistenceOwnerTest {
|
||||
@Test void replacementClassLoaderCannotBypassExistingCanonicalOwner(@TempDir Path directory) throws Exception {
|
||||
Path path = directory.resolve("quests.yml");
|
||||
QuestPersistenceOwner first = QuestPersistenceOwner.acquire(path, this);
|
||||
try (URLClassLoader replacement = new URLClassLoader(
|
||||
new java.net.URL[]{QuestPersistenceOwner.class.getProtectionDomain().getCodeSource().getLocation()},
|
||||
ClassLoader.getPlatformClassLoader())) {
|
||||
Class<?> isolatedOwner = Class.forName(QuestPersistenceOwner.class.getName(), true, replacement);
|
||||
assertNotSame(QuestPersistenceOwner.class, isolatedOwner);
|
||||
var acquire = isolatedOwner.getDeclaredMethod("acquire", Path.class, Object.class);
|
||||
acquire.setAccessible(true);
|
||||
InvocationTargetException failure = assertThrows(InvocationTargetException.class,
|
||||
() -> acquire.invoke(null, directory.resolve(".").resolve("quests.yml"), new Object()));
|
||||
assertInstanceOf(IOException.class, failure.getCause());
|
||||
assertTrue(failure.getCause().getMessage().contains("earlier runtime"));
|
||||
first.release();
|
||||
Object next = acquire.invoke(null, path, new Object());
|
||||
var release = isolatedOwner.getDeclaredMethod("release");
|
||||
release.setAccessible(true);
|
||||
release.invoke(next);
|
||||
} finally {
|
||||
// The normal success path already released this lock.
|
||||
try { first.release(); } catch (IOException ignored) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.time.*;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class QuestRuntimeCommandTest {
|
||||
private static final Instant AT = Instant.parse("2026-01-01T00:00:00Z");
|
||||
@Test void everyLegacyCommandMutationUsesRuntimeActions() throws Exception {
|
||||
QuestActions actions = mock(QuestActions.class);
|
||||
QuestService service = mock(QuestService.class);
|
||||
QuestCommand command = new QuestCommand(actions, service, Clock.fixed(AT, ZoneOffset.UTC), null, null);
|
||||
Player player = mock(Player.class);
|
||||
Command nativeCommand = mock(Command.class);
|
||||
command.onCommand(player, nativeCommand, "quests", new String[]{"create", "stone", "3"});
|
||||
command.onCommand(player, nativeCommand, "quests", new String[]{"complete", "id"});
|
||||
command.onCommand(player, nativeCommand, "quests", new String[]{"cancel", "id"});
|
||||
command.onCommand(player, nativeCommand, "quests", new String[]{"claim"});
|
||||
verify(actions).create(player, "stone", 3, AT);
|
||||
verify(actions).complete(player, "id", AT);
|
||||
verify(actions).cancel(player, "id", AT);
|
||||
verify(actions).collect(player);
|
||||
verifyNoInteractions(service);
|
||||
}
|
||||
|
||||
@Test void disabledCommandsStillRejectMutationsAndTabCompletionAwayFromBoards() throws Exception {
|
||||
QuestActions actions = mock(QuestActions.class);
|
||||
QuestService service = mock(QuestService.class);
|
||||
PlayerCommandSettings settings = new PlayerCommandSettings(new PlayerCommandSettingsRepository() {
|
||||
@Override public boolean loadEnabled() { return false; }
|
||||
@Override public void saveEnabled(boolean enabled) { fail("No settings write"); }
|
||||
});
|
||||
QuestCommand command = new QuestCommand(actions, service, Clock.fixed(AT, ZoneOffset.UTC), settings, null);
|
||||
Player player = mock(Player.class);
|
||||
Command nativeCommand = mock(Command.class);
|
||||
for (String[] input : new String[][]{{"create", "stone", "3"}, {"complete", "id"}, {"cancel", "id"}, {"claim"}}) {
|
||||
command.onCommand(player, nativeCommand, "quests", input);
|
||||
}
|
||||
assertTrue(command.onTabComplete(player, nativeCommand, "quests", new String[]{""}).isEmpty());
|
||||
verifyNoInteractions(actions, service);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class QuestRuntimeSchedulerTest {
|
||||
@Test void dispatchesOnCorrectLanesAndClosesWithoutWaitingForWorkerOrAbandoningAcceptedCallbacks() {
|
||||
ManualWorker worker = new ManualWorker();
|
||||
Queue<Runnable> main = new ArrayDeque<>();
|
||||
boolean[] onMain = {true};
|
||||
QuestRuntimeScheduler runtime = new QuestRuntimeScheduler(worker, main::add, () -> onMain[0]);
|
||||
List<String> events = new ArrayList<>();
|
||||
runtime.worker().execute(() -> {
|
||||
assertFalse(onMain[0]);
|
||||
events.add("save");
|
||||
runtime.main().execute(() -> {
|
||||
assertTrue(onMain[0]);
|
||||
events.add("deliver");
|
||||
runtime.worker().execute(() -> events.add("ack"));
|
||||
});
|
||||
});
|
||||
assertTrue(events.isEmpty(), "Persistence must not run inline on tick");
|
||||
onMain[0] = false;
|
||||
worker.runOne();
|
||||
onMain[0] = true;
|
||||
runtime.close(() -> events.add("closed"));
|
||||
assertEquals(List.of("save", "deliver"), events, "Accepted inventory callback must settle before shutdown, without waiting for worker");
|
||||
assertThrows(RejectedExecutionException.class, () -> runtime.main().execute(() -> fail("late Bukkit callback")));
|
||||
onMain[0] = false;
|
||||
worker.drain();
|
||||
assertEquals(List.of("save", "deliver", "ack", "closed"), events);
|
||||
onMain[0] = true;
|
||||
while (!main.isEmpty()) { main.remove().run(); }
|
||||
assertEquals(4, events.size(), "Scheduled wrappers become inert after disable");
|
||||
assertTrue(worker.isShutdown());
|
||||
}
|
||||
|
||||
@Test void rejectedMainCallbacksDuringWorkerDrainCanPersistRecoveryBeforeFinalShutdown() {
|
||||
ManualWorker worker = new ManualWorker();
|
||||
boolean[] onMain = {true};
|
||||
QuestRuntimeScheduler runtime = new QuestRuntimeScheduler(worker, ignored -> fail("No scheduling after disable"), () -> onMain[0]);
|
||||
List<String> events = new ArrayList<>();
|
||||
runtime.worker().execute(() -> {
|
||||
try { runtime.main().execute(() -> fail("Unsafe disabled callback")); }
|
||||
catch (RejectedExecutionException expected) {
|
||||
runtime.worker().execute(() -> events.add("recovery persisted"));
|
||||
}
|
||||
});
|
||||
runtime.close(() -> events.add("audit reservations"));
|
||||
assertTrue(events.isEmpty());
|
||||
onMain[0] = false;
|
||||
worker.drain();
|
||||
assertEquals(List.of("recovery persisted", "audit reservations"), events);
|
||||
assertThrows(RejectedExecutionException.class, () -> runtime.worker().execute(() -> fail("new operation after shutdown")));
|
||||
}
|
||||
|
||||
@Test void orderlyJvmExitWaitsForDiskOnlyDrainNotJustNonDaemonThread(@org.junit.jupiter.api.io.TempDir java.nio.file.Path directory) throws Exception {
|
||||
String classpath = java.nio.file.Path.of(QuestRuntimeScheduler.class.getProtectionDomain().getCodeSource().getLocation().toURI())
|
||||
+ java.io.File.pathSeparator
|
||||
+ java.nio.file.Path.of(QuestShutdownProcess.class.getProtectionDomain().getCodeSource().getLocation().toURI());
|
||||
java.nio.file.Path saved = directory.resolve("saved.txt");
|
||||
java.nio.file.Path log = directory.resolve("process.log");
|
||||
Process process = new ProcessBuilder(java.nio.file.Path.of(System.getProperty("java.home"), "bin", "java").toString(),
|
||||
"-cp", classpath, QuestShutdownProcess.class.getName(), saved.toString())
|
||||
.redirectErrorStream(true).redirectOutput(log.toFile()).start();
|
||||
try {
|
||||
assertTrue(process.waitFor(10, TimeUnit.SECONDS), "shutdown must finish without a main-thread join");
|
||||
assertEquals(0, process.exitValue(), java.nio.file.Files.readString(log));
|
||||
assertTrue(java.nio.file.Files.exists(saved), "System.exit killed accepted disk work: non-daemon alone is not a stop boundary");
|
||||
assertEquals("accepted escrow persisted", java.nio.file.Files.readString(saved));
|
||||
} finally { process.destroyForcibly(); }
|
||||
}
|
||||
|
||||
static final class ManualWorker extends AbstractExecutorService {
|
||||
final Queue<Runnable> queue = new ArrayDeque<>();
|
||||
private boolean shutdown;
|
||||
@Override public void execute(Runnable task) {
|
||||
if (shutdown) { throw new RejectedExecutionException(); }
|
||||
queue.add(task);
|
||||
}
|
||||
void runOne() { queue.remove().run(); }
|
||||
void drain() { while (!queue.isEmpty()) { runOne(); } }
|
||||
@Override public void shutdown() { shutdown = true; }
|
||||
@Override public List<Runnable> shutdownNow() { throw new AssertionError("Never discard queue"); }
|
||||
@Override public boolean isShutdown() { return shutdown; }
|
||||
@Override public boolean isTerminated() { return shutdown && queue.isEmpty(); }
|
||||
@Override public boolean awaitTermination(long timeout, TimeUnit unit) { throw new AssertionError("Never wait on tick"); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.command.PluginCommand;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.bukkit.scheduler.BukkitScheduler;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
class QuestRuntimeWiringTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test void productionStartupLoadsOnWorkerThenRegistersCommandsBoardsDeathJoinAndExpiryOnMain() throws Exception {
|
||||
QuestRuntimeSchedulerTest.ManualWorker worker = new QuestRuntimeSchedulerTest.ManualWorker();
|
||||
Queue<Runnable> main = new ArrayDeque<>();
|
||||
boolean[] onMain = {true};
|
||||
QuestRuntimeScheduler runtime = new QuestRuntimeScheduler(worker, main::add, () -> onMain[0]);
|
||||
SpigotQuestBoardPlugin plugin = mock(SpigotQuestBoardPlugin.class, CALLS_REAL_METHODS);
|
||||
Server server = mock(Server.class);
|
||||
PluginManager manager = mock(PluginManager.class);
|
||||
BukkitScheduler scheduler = mock(BukkitScheduler.class);
|
||||
QuestActions actions = mock(QuestActions.class);
|
||||
doReturn(server).when(plugin).getServer();
|
||||
doReturn(directory.toFile()).when(plugin).getDataFolder();
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
doReturn(runtime).when(plugin).createRuntime();
|
||||
doReturn(actions).when(plugin).runtimeActions(any(), eq(runtime));
|
||||
doReturn(mock(PluginCommand.class)).when(plugin).getCommand(anyString());
|
||||
when(server.getPluginManager()).thenReturn(manager);
|
||||
when(server.getScheduler()).thenReturn(scheduler);
|
||||
try (var repositories = mockConstruction(YamlQuestRepository.class, (repository, context) -> {
|
||||
when(repository.load()).thenAnswer(call -> { assertFalse(onMain[0], "Initial quest YAML read must leave tick"); return QuestState.empty(); });
|
||||
})) {
|
||||
plugin.onEnable();
|
||||
assertTrue(repositories.constructed().isEmpty(), "Startup returns before disk read");
|
||||
verify(manager).registerEvents(isA(BountyDeathListener.class), eq(plugin));
|
||||
onMain[0] = false;
|
||||
worker.drain();
|
||||
verify(manager).registerEvents(isA(BountyDeathListener.class), eq(plugin));
|
||||
onMain[0] = true;
|
||||
while (!main.isEmpty()) { main.remove().run(); }
|
||||
assertEquals(1, repositories.constructed().size());
|
||||
var listeners = ArgumentCaptor.forClass(Listener.class);
|
||||
verify(manager, atLeastOnce()).registerEvents(listeners.capture(), eq(plugin));
|
||||
assertTrue(listeners.getAllValues().stream().anyMatch(QuestBoardInteractionListener.class::isInstance));
|
||||
BountyDeathListener death = listeners.getAllValues().stream().filter(BountyDeathListener.class::isInstance)
|
||||
.map(BountyDeathListener.class::cast).findFirst().orElseThrow();
|
||||
Player target = mock(Player.class);
|
||||
Player hunter = mock(Player.class);
|
||||
when(target.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(hunter.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(target.getKiller()).thenReturn(hunter);
|
||||
PlayerDeathEvent event = mock(PlayerDeathEvent.class);
|
||||
when(event.getEntity()).thenReturn(target);
|
||||
death.onDeath(event);
|
||||
UUID targetId = target.getUniqueId();
|
||||
UUID hunterId = hunter.getUniqueId();
|
||||
verify(actions).death(any(), eq(targetId), eq(hunterId), any());
|
||||
PlayerJoinEvent joined = mock(PlayerJoinEvent.class);
|
||||
when(joined.getPlayer()).thenReturn(hunter);
|
||||
boolean joinFound = false;
|
||||
for (Listener listener : listeners.getAllValues()) {
|
||||
for (var method : listener.getClass().getDeclaredMethods()) {
|
||||
if (Arrays.equals(method.getParameterTypes(), new Class<?>[]{PlayerJoinEvent.class})) {
|
||||
method.invoke(listener, joined);
|
||||
joinFound = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(joinFound);
|
||||
verify(actions).joined(hunter);
|
||||
var expiry = ArgumentCaptor.forClass(Runnable.class);
|
||||
verify(scheduler).runTaskTimer(eq(plugin), expiry.capture(), eq(0L), eq(20L));
|
||||
expiry.getValue().run();
|
||||
verify(actions).expire(any());
|
||||
verify(plugin).runtimeActions(any(), eq(runtime));
|
||||
verify(plugin, atLeastOnce()).getCommand("quests");
|
||||
plugin.onDisable();
|
||||
onMain[0] = false;
|
||||
worker.drain();
|
||||
onMain[0] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/** Forked-JVM fixture: System.exit does not wait for a non-daemon writer without our hook. */
|
||||
public final class QuestShutdownProcess {
|
||||
private QuestShutdownProcess() { }
|
||||
public static void main(String[] args) {
|
||||
Thread main = Thread.currentThread();
|
||||
QuestRuntimeScheduler runtime = new QuestRuntimeScheduler(
|
||||
Executors.newSingleThreadExecutor(Thread.ofPlatform().daemon(false).factory()),
|
||||
task -> { throw new AssertionError("No Bukkit callbacks may be needed during final disk drain"); },
|
||||
() -> Thread.currentThread() == main);
|
||||
runtime.installShutdownBoundary(Logger.getLogger("shutdown-process"));
|
||||
runtime.worker().execute(() -> {
|
||||
try {
|
||||
Thread.sleep(300);
|
||||
Files.writeString(Path.of(args[0]), "accepted escrow persisted");
|
||||
} catch (Exception exception) { throw new AssertionError(exception); }
|
||||
});
|
||||
runtime.closeRecovering(() -> true);
|
||||
System.exit(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class QuestShutdownRecoveryTest {
|
||||
private static final Instant AT = Instant.parse("2026-01-01T00:00:00Z");
|
||||
private static final List<EscrowItem> ITEMS = List.of(new EscrowItem("DIAMOND", 3, "AA==", "Exact metadata"));
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test void canonicalOwnerProtectsAcceptedEscrowAcrossPausedRepositoryWriteAndReenable() throws Exception {
|
||||
Path alias = directory.resolve("alias");
|
||||
java.nio.file.Files.createSymbolicLink(alias, directory);
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
try (Fixture first = new Fixture(directory)) {
|
||||
first.harness.enable();
|
||||
first.repository.saveEntered = entered;
|
||||
first.repository.releaseSave = release;
|
||||
try {
|
||||
first.actions.get().postBounty(first.player, UUID.randomUUID(), "Target", 1, AT);
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS), "accepted input must reach repository save");
|
||||
assertEquals(1, first.removals.get());
|
||||
first.harness.plugin.onDisable();
|
||||
assertFalse(first.harness.runtime.drainCompletion().toCompletableFuture().isDone());
|
||||
try (Fixture blocked = new Fixture(alias)) {
|
||||
blocked.harness.enable();
|
||||
assertNull(blocked.harness.service.get(), "canonical aliases must not bypass ownership");
|
||||
}
|
||||
release.countDown();
|
||||
assertTrue(first.harness.worker.awaitTermination(5, TimeUnit.SECONDS));
|
||||
try (Fixture next = new Fixture(alias)) {
|
||||
next.harness.enable();
|
||||
assertEquals(1, next.harness.service.get().state().bounties().size());
|
||||
next.actions.get().postBounty(next.player, UUID.randomUUID(), "Second", 1, AT);
|
||||
next.cycle();
|
||||
assertEquals(2, next.disk.load().bounties().size(), "later snapshots must preserve accepted exact escrow");
|
||||
assertTrue(next.disk.load().bounties().values().stream().allMatch(bounty -> bounty.reward().equals(ITEMS)));
|
||||
}
|
||||
} finally { release.countDown(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualDisableRecoversOfflineInputThenReloadCollectsExactItemsOnce() throws Exception {
|
||||
try (Fixture fixture = new Fixture(directory)) {
|
||||
fixture.harness.enable();
|
||||
fixture.repository.fail = true;
|
||||
fixture.actions.get().postBounty(fixture.player, UUID.randomUUID(), "Target", 1, AT);
|
||||
fixture.harness.fence();
|
||||
when(fixture.player.isOnline()).thenReturn(false);
|
||||
fixture.harness.pumpMain();
|
||||
fixture.harness.fence();
|
||||
assertTrue(fixture.harness.service.get().state().transfers().isEmpty());
|
||||
fixture.repository.fail = false;
|
||||
fixture.harness.disable();
|
||||
assertTrue(fixture.harness.runtime.drainCompletion().toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
try (Fixture restarted = new Fixture(directory, fixture.owner)) {
|
||||
restarted.harness.enable();
|
||||
assertEquals(ITEMS, restarted.harness.service.get().state().transfers().values().iterator().next().items());
|
||||
restarted.actions.get().collect(restarted.player);
|
||||
restarted.cycle();
|
||||
restarted.actions.get().collect(restarted.player);
|
||||
restarted.cycle();
|
||||
assertEquals(1, restarted.deliveries.get());
|
||||
assertTrue(restarted.disk.load().transfers().isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualDisableRetriesExhaustedDeathFifoAndPersistsOriginalWinner() throws Exception {
|
||||
try (Fixture fixture = new Fixture(directory)) {
|
||||
UUID target = UUID.randomUUID();
|
||||
UUID first = UUID.randomUUID();
|
||||
UUID later = UUID.randomUUID();
|
||||
new QuestService(fixture.disk).createBounty(fixture.owner, "Issuer", target, "Target", 1, ITEMS, AT);
|
||||
fixture.harness.enable();
|
||||
fixture.repository.fail = true;
|
||||
fixture.actions.get().death(UUID.randomUUID(), target, first, AT.plusSeconds(1));
|
||||
fixture.actions.get().death(UUID.randomUUID(), target, later, AT.plusSeconds(2));
|
||||
fixture.harness.fence();
|
||||
assertTrue(fixture.harness.service.get().claimsFor(first).isEmpty());
|
||||
fixture.repository.fail = false;
|
||||
fixture.harness.disable();
|
||||
QuestService reloaded = new QuestService(fixture.disk);
|
||||
assertEquals(1, reloaded.claimsFor(first).size());
|
||||
assertEquals(AT.plusSeconds(1), reloaded.claimsFor(first).getFirst().createdAt());
|
||||
assertTrue(reloaded.claimsFor(later).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualDisableSettlesKnownDeliveredAndUndeliveredOutcomesWithoutGuessingReservedOnReload() throws Exception {
|
||||
for (boolean delivered : List.of(true, false)) {
|
||||
Path folder = directory.resolve(Boolean.toString(delivered));
|
||||
UUID owner;
|
||||
try (Fixture fixture = new Fixture(folder)) {
|
||||
owner = fixture.owner;
|
||||
fixture.seedClaim();
|
||||
fixture.harness.enable();
|
||||
fixture.actions.get().collect(fixture.player);
|
||||
fixture.harness.fence(); // Reservation is already durable.
|
||||
fixture.repository.fail = true;
|
||||
when(fixture.player.isOnline()).thenReturn(delivered);
|
||||
fixture.harness.pumpMain();
|
||||
fixture.harness.fence(); // Exhaust acknowledgement/release retries.
|
||||
assertEquals(1, fixture.disk.load().transfers().size());
|
||||
fixture.repository.fail = false;
|
||||
fixture.harness.disable();
|
||||
assertEquals(delivered ? 1 : 0, fixture.deliveries.get());
|
||||
assertTrue(fixture.disk.load().transfers().isEmpty());
|
||||
}
|
||||
try (Fixture restarted = new Fixture(folder, owner)) {
|
||||
restarted.harness.enable();
|
||||
restarted.actions.get().collect(restarted.player);
|
||||
restarted.cycle();
|
||||
restarted.actions.get().collect(restarted.player);
|
||||
restarted.cycle();
|
||||
assertEquals(delivered ? 0 : 1, restarted.deliveries.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualDisablePersistsRetainedNotificationAcknowledgement() throws Exception {
|
||||
try (Fixture fixture = new Fixture(directory)) {
|
||||
QuestService seed = new QuestService(fixture.disk);
|
||||
UUID target = UUID.randomUUID();
|
||||
seed.createBounty(UUID.randomUUID(), "Issuer", target, "Target", 1, ITEMS, AT);
|
||||
seed.recordDeath(UUID.randomUUID(), target, fixture.owner, AT.plusSeconds(1));
|
||||
fixture.harness.enable();
|
||||
fixture.repository.fail = true;
|
||||
fixture.actions.get().joined(fixture.player);
|
||||
fixture.harness.fence();
|
||||
assertFalse(fixture.harness.service.get().pendingNotifications(fixture.owner).isEmpty());
|
||||
fixture.repository.fail = false;
|
||||
fixture.harness.disable();
|
||||
assertTrue(new QuestService(fixture.disk).pendingNotifications(fixture.owner).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void permanentFailureExportsExactInputAndBlocksReplacementInsteadOfClaimingDrainSuccess() throws Exception {
|
||||
try (Fixture fixture = new Fixture(directory)) {
|
||||
fixture.harness.enable();
|
||||
fixture.repository.fail = true;
|
||||
fixture.actions.get().postBounty(fixture.player, UUID.randomUUID(), "Target", 1, AT);
|
||||
fixture.harness.fence();
|
||||
when(fixture.player.isOnline()).thenReturn(false);
|
||||
fixture.harness.pumpMain();
|
||||
fixture.harness.fence();
|
||||
fixture.harness.disable();
|
||||
assertFalse(fixture.harness.runtime.drainCompletion().toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
verify(fixture.harness.logger).severe(argThat((String message) -> message.contains("INCOMPLETE")
|
||||
&& message.contains("serializedItem=AA==") && message.contains(fixture.owner.toString())
|
||||
&& message.contains("amount=3")));
|
||||
verify(fixture.harness.logger, never()).info(contains("ownership released"));
|
||||
try (Fixture replacement = new Fixture(directory)) {
|
||||
replacement.harness.enable();
|
||||
assertNull(replacement.harness.service.get(), "unresolved owner must prevent a stale replacement");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void permanentDeathFailureExportsOriginalAttributionAndTimeAndRetainsOwnership() throws Exception {
|
||||
try (Fixture fixture = new Fixture(directory)) {
|
||||
UUID target = UUID.randomUUID();
|
||||
UUID hunter = UUID.randomUUID();
|
||||
UUID event = UUID.randomUUID();
|
||||
new QuestService(fixture.disk).createBounty(fixture.owner, "Issuer", target, "Target", 1, ITEMS, AT);
|
||||
fixture.harness.enable();
|
||||
fixture.repository.fail = true;
|
||||
fixture.actions.get().death(event, target, hunter, AT.plusSeconds(1));
|
||||
fixture.harness.fence();
|
||||
fixture.harness.disable();
|
||||
assertFalse(fixture.harness.runtime.drainCompletion().toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
verify(fixture.harness.logger).severe(argThat((String message) -> message.contains("INCOMPLETE")
|
||||
&& message.contains(target.toString()) && message.contains(hunter.toString())
|
||||
&& message.contains(event.toString()) && message.contains(AT.plusSeconds(1).toString())));
|
||||
}
|
||||
}
|
||||
|
||||
static final class Fixture implements AutoCloseable {
|
||||
final QuestLifecycleRecoveryTest.Harness harness;
|
||||
final YamlQuestRepository disk;
|
||||
final SwitchableRepository repository;
|
||||
final UUID owner;
|
||||
final Player player = mock(Player.class);
|
||||
final AtomicInteger deliveries = new AtomicInteger();
|
||||
final AtomicInteger removals = new AtomicInteger();
|
||||
final AtomicReference<AsyncQuestActions> actions = new AtomicReference<>();
|
||||
Fixture(Path folder) { this(folder, UUID.randomUUID()); }
|
||||
Fixture(Path folder, UUID owner) {
|
||||
this.owner = owner;
|
||||
harness = new QuestLifecycleRecoveryTest.Harness(folder);
|
||||
disk = new YamlQuestRepository(folder.resolve("quests.yml"));
|
||||
repository = new SwitchableRepository(disk, harness.mainThread);
|
||||
doReturn(repository).when(harness.plugin).questRepository(any(), eq(harness.runtime));
|
||||
when(player.getUniqueId()).thenReturn(owner);
|
||||
when(player.getName()).thenReturn("Issuer");
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
QuestInputInventory inputs = mock(QuestInputInventory.class);
|
||||
QuestInputInventory.Reservation reservation = mock(QuestInputInventory.Reservation.class);
|
||||
when(inputs.removeReward(player)).thenAnswer(call -> { removals.incrementAndGet(); return reservation; });
|
||||
when(reservation.items()).thenReturn(ITEMS);
|
||||
QuestClaimInventory inventory = items -> {
|
||||
assertSame(harness.mainThread, Thread.currentThread());
|
||||
assertEquals(ITEMS, items);
|
||||
return recipient -> {
|
||||
assertSame(harness.mainThread, Thread.currentThread());
|
||||
deliveries.incrementAndGet();
|
||||
return new QuestClaimInventory.Delivery() {
|
||||
@Override public int overflowAmount() { return 0; }
|
||||
@Override public void rollback() { fail("No rollback after known delivery"); }
|
||||
};
|
||||
};
|
||||
};
|
||||
doAnswer(call -> {
|
||||
QuestService service = call.getArgument(0);
|
||||
harness.service.set(service);
|
||||
AsyncQuestActions adapter = new AsyncQuestActions(service, harness.runtime.worker(), harness.runtime.main(),
|
||||
inputs, inventory, mock(ItemMaterialCatalog.class), id -> null, harness.logger);
|
||||
actions.set(adapter);
|
||||
return adapter;
|
||||
}).when(harness.plugin).runtimeActions(any(), eq(harness.runtime));
|
||||
}
|
||||
void seedClaim() throws IOException {
|
||||
QuestClaim claim = new QuestClaim(UUID.randomUUID(), UUID.randomUUID(), owner, ITEMS, AT, QuestClaimSource.BOUNTY);
|
||||
disk.save(new QuestState(java.util.Map.of(), java.util.Map.of(owner, List.of(claim)), java.util.Map.of()));
|
||||
}
|
||||
void cycle() throws Exception { harness.fence(); harness.pumpMain(); harness.fence(); harness.pumpMain(); harness.fence(); }
|
||||
@Override public void close() {
|
||||
try { harness.disable(); }
|
||||
catch (Exception exception) { throw new AssertionError(exception); }
|
||||
}
|
||||
}
|
||||
|
||||
static final class SwitchableRepository implements QuestRepository {
|
||||
final QuestRepository delegate;
|
||||
final Thread main;
|
||||
volatile boolean fail;
|
||||
volatile java.util.concurrent.CountDownLatch saveEntered;
|
||||
volatile java.util.concurrent.CountDownLatch releaseSave;
|
||||
SwitchableRepository(QuestRepository delegate, Thread main) { this.delegate = delegate; this.main = main; }
|
||||
@Override public QuestState load() throws IOException {
|
||||
assertNotSame(main, Thread.currentThread());
|
||||
return delegate.load();
|
||||
}
|
||||
@Override public void save(QuestState state) throws IOException {
|
||||
assertNotSame(main, Thread.currentThread());
|
||||
if (saveEntered != null) {
|
||||
saveEntered.countDown();
|
||||
QuestLifecycleRecoveryTest.await(releaseSave);
|
||||
}
|
||||
if (fail) { throw new IOException("controlled storage outage"); }
|
||||
delegate.save(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ final class YamlBoardRepositoryTest {
|
||||
assertEquals(BoardRegistrationResult.CREATED, first.registerAll(generated));
|
||||
BoardRegistry restarted = new BoardRegistry(new YamlBoardRepository(path));
|
||||
|
||||
assertEquals(15, restarted.size());
|
||||
assertEquals(17, restarted.size());
|
||||
assertTrue(generated.stream().allMatch(board -> restarted.contains(board.id())));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
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.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
@@ -12,8 +15,14 @@ import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
final class YamlQuestRepositoryTest {
|
||||
@TempDir Path temporaryDirectory;
|
||||
@@ -53,6 +62,90 @@ final class YamlQuestRepositoryTest {
|
||||
assertTrue(yaml.contains(itemData));
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistsCustomNamesForRewardAndClaimItems() throws Exception {
|
||||
Path path = temporaryDirectory.resolve("quests.yml");
|
||||
YamlQuestRepository repository = new YamlQuestRepository(path);
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID questId = UUID.randomUUID();
|
||||
Quest quest = new Quest(
|
||||
questId, ownerId, "Issuer", "STONE", 1,
|
||||
List.of(new EscrowItem("PAPER", 1, "reward-data", "Shopping List")),
|
||||
Instant.parse("2026-09-05T03:00:00Z"),
|
||||
Instant.parse("2026-09-12T03:00:00Z"), QuestStatus.COMPLETED
|
||||
);
|
||||
QuestClaim claim = new QuestClaim(
|
||||
UUID.randomUUID(), questId, ownerId,
|
||||
List.of(new EscrowItem("BOOK", 1, "claim-data", "Field Notes")),
|
||||
Instant.parse("2026-09-05T03:01:00Z")
|
||||
);
|
||||
QuestState state = new QuestState(
|
||||
Map.of(questId, quest), Map.of(ownerId, List.of(claim)), Map.of()
|
||||
);
|
||||
|
||||
repository.save(state);
|
||||
|
||||
assertEquals(state, repository.load());
|
||||
String yaml = Files.readString(path);
|
||||
assertTrue(yaml.contains("custom-name: Shopping List"));
|
||||
assertTrue(yaml.contains("custom-name: Field Notes"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void infersHistoricalCustomNameFromExactDataAndPersistsItOnNextSave() throws Exception {
|
||||
Path path = temporaryDirectory.resolve("quests.yml");
|
||||
String itemData = Base64.getEncoder().encodeToString(new byte[] {7});
|
||||
writeHistoricalQuest(path, itemData);
|
||||
ItemStack stack = mock(ItemStack.class);
|
||||
ItemMeta metadata = mock(ItemMeta.class);
|
||||
when(stack.getType()).thenReturn(Material.PAPER);
|
||||
when(stack.getAmount()).thenReturn(1);
|
||||
when(stack.hasItemMeta()).thenReturn(true);
|
||||
when(stack.getItemMeta()).thenReturn(metadata);
|
||||
when(metadata.hasCustomName()).thenReturn(true);
|
||||
when(metadata.customName()).thenReturn(Component.text("Archived List"));
|
||||
YamlQuestRepository repository = new YamlQuestRepository(path);
|
||||
|
||||
QuestState loaded;
|
||||
try (MockedStatic<ItemStack> itemStacks = Mockito.mockStatic(ItemStack.class)) {
|
||||
itemStacks.when(() -> ItemStack.deserializeBytes(Mockito.any(byte[].class)))
|
||||
.thenReturn(stack);
|
||||
loaded = repository.load();
|
||||
}
|
||||
|
||||
assertEquals("Archived List", loaded.quests().values().iterator().next()
|
||||
.reward().getFirst().customName());
|
||||
assertEquals("Archived List", loaded.claims().values().iterator().next()
|
||||
.getFirst().items().getFirst().customName());
|
||||
repository.save(loaded);
|
||||
assertTrue(Files.readString(path).contains("custom-name: Archived List"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedOrUnavailableHistoricalItemDataFallsBackWithoutFailingLoad() throws Exception {
|
||||
Path malformedPath = temporaryDirectory.resolve("malformed.yml");
|
||||
writeHistoricalQuest(malformedPath, "not-base64%%% ");
|
||||
|
||||
Quest malformedQuest = new YamlQuestRepository(malformedPath).load()
|
||||
.quests().values().iterator().next();
|
||||
|
||||
assertNull(malformedQuest.reward().getFirst().customName());
|
||||
assertTrue(QuestListingFormatter.format(
|
||||
malformedQuest, Instant.parse("2026-09-06T03:00:00Z")
|
||||
).contains("1 × PAPER (with exact item data)"));
|
||||
|
||||
Path unavailablePath = temporaryDirectory.resolve("unavailable.yml");
|
||||
writeHistoricalQuest(unavailablePath, Base64.getEncoder().encodeToString(new byte[] {8}));
|
||||
Quest unavailableQuest;
|
||||
try (MockedStatic<ItemStack> itemStacks = Mockito.mockStatic(ItemStack.class)) {
|
||||
itemStacks.when(() -> ItemStack.deserializeBytes(Mockito.any(byte[].class)))
|
||||
.thenThrow(new IllegalStateException("server item codec unavailable"));
|
||||
unavailableQuest = new YamlQuestRepository(unavailablePath).load()
|
||||
.quests().values().iterator().next();
|
||||
}
|
||||
assertNull(unavailableQuest.reward().getFirst().customName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingLifecycleStatusDefaultsToActiveForExistingYaml() throws Exception {
|
||||
Path path = temporaryDirectory.resolve("quests.yml");
|
||||
@@ -161,6 +254,32 @@ final class YamlQuestRepositoryTest {
|
||||
assertEquals(QuestClaimType.RETURNED_REWARD, claim.type());
|
||||
}
|
||||
|
||||
private static void writeHistoricalQuest(Path path, String itemData) throws IOException {
|
||||
Files.writeString(path, """
|
||||
quests:
|
||||
- id: 00000000-0000-0000-0000-000000000010
|
||||
issuer-id: 00000000-0000-0000-0000-000000000001
|
||||
issuer-name: Issuer
|
||||
requested-material: STONE
|
||||
requested-amount: 1
|
||||
created-at: '2026-09-05T03:00:00Z'
|
||||
expires-at: '2026-09-12T03:00:00Z'
|
||||
reward:
|
||||
- material: PAPER
|
||||
amount: 1
|
||||
item-data: '%s'
|
||||
claims:
|
||||
- id: 00000000-0000-0000-0000-000000000020
|
||||
quest-id: 00000000-0000-0000-0000-000000000010
|
||||
owner-id: 00000000-0000-0000-0000-000000000001
|
||||
created-at: '2026-09-05T04:00:00Z'
|
||||
items:
|
||||
- material: PAPER
|
||||
amount: 1
|
||||
item-data: '%s'
|
||||
""".formatted(itemData, itemData));
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedStateIsRejectedRatherThanPartiallyLoaded() throws Exception {
|
||||
Path path = temporaryDirectory.resolve("quests.yml");
|
||||
|
||||
Reference in new issue
Block a user