Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a4d0c530b | ||
|
|
7e60224096 | ||
|
|
3f721e928a | ||
|
|
5bfcf36f04 | ||
|
|
e4bc296ce1 | ||
|
|
7edfe71d2d | ||
|
|
79c9c7fea7 |
No files matched your search
@@ -13,6 +13,6 @@ while IFS='=' read -r key value && [ -n "$key" ]; do
|
||||
done
|
||||
[ "$protocol" = https ] || exit 0
|
||||
[ "$host" = git.garvis.dev ] || exit 0
|
||||
[ "$path" = dmg/spigot-ignis.git ] || exit 0
|
||||
[ "$path" = dmg/purpur-ignis.git ] || exit 0
|
||||
[ -n "${IGNIS_RELEASE_TOKEN:-}" ] || exit 0
|
||||
printf 'username=dmg\npassword=%s\n\n' "$IGNIS_RELEASE_TOKEN"
|
||||
@@ -35,6 +35,6 @@ jobs:
|
||||
- name: Upload development artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: spigot-ignis-${{ github.sha }}
|
||||
name: purpur-ignis-${{ github.sha }}
|
||||
path: build/libs/*.jar
|
||||
if-no-files-found: error
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
test -n "$IGNIS_RELEASE_TOKEN" || { echo 'RELEASE_TOKEN is required'; exit 1; }
|
||||
test "$GITEA_SERVER_URL" = 'https://git.garvis.dev'
|
||||
test "$REPOSITORY" = 'dmg/spigot-ignis'
|
||||
test "$REPOSITORY" = 'dmg/purpur-ignis'
|
||||
# semantic-release overrides GIT_ASKPASS; use Git's credential protocol instead.
|
||||
# Token stays in environment/credential stdout, never in the remote URL or argv.
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
@@ -85,8 +85,8 @@ jobs:
|
||||
if: steps.release.outputs.created == 'true'
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: spigot-ignis-${{ steps.release.outputs.version }}
|
||||
path: build/libs/spigot-ignis-${{ steps.release.outputs.version }}.jar
|
||||
name: purpur-ignis-${{ steps.release.outputs.version }}
|
||||
path: build/libs/purpur-ignis-${{ steps.release.outputs.version }}.jar
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Publish versioned Gitea asset
|
||||
@@ -102,9 +102,9 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$GITEA_SERVER_URL" = 'https://git.garvis.dev'
|
||||
test "$REPOSITORY" = 'dmg/spigot-ignis'
|
||||
test "$REPOSITORY" = 'dmg/purpur-ignis'
|
||||
api_url="${GITEA_SERVER_URL}/api/v1/repos/${REPOSITORY}/releases"
|
||||
jar="build/libs/spigot-ignis-${VERSION}.jar"
|
||||
jar="build/libs/purpur-ignis-${VERSION}.jar"
|
||||
payload=$(mktemp)
|
||||
response=$(mktemp)
|
||||
trap 'rm -f "$payload" "$response"' EXIT
|
||||
@@ -127,4 +127,4 @@ jobs:
|
||||
curl --fail --silent --show-error --max-time 120 --proto '=https' \
|
||||
--header @- --header 'Content-Type: application/octet-stream' \
|
||||
--data-binary "@$jar" --output /dev/null \
|
||||
"${api_url}/${release_id}/assets?name=spigot-ignis-${VERSION}.jar"
|
||||
"${api_url}/${release_id}/assets?name=purpur-ignis-${VERSION}.jar"
|
||||
@@ -1,8 +1,8 @@
|
||||
# Spigot Ignis agent entrypoint
|
||||
# Purpur Ignis agent entrypoint
|
||||
|
||||
The canonical knowledge bundle is the sibling `../somc-okf/` repository, remotely at https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md.
|
||||
|
||||
Before changes read its root index, `processes/development.md`, `runbooks/development-cycle.md`, `projects/spigot-ignis/index.md`, `projects/spigot-ignis/engineering.md`, and the relevant `user-stories/spigot-ignis/` documents. Follow the parent workspace instructions. Do not create a local knowledge/design bundle.
|
||||
Before changes read its root index, `processes/development.md`, `runbooks/development-cycle.md`, `projects/purpur-ignis/index.md`, `projects/purpur-ignis/engineering.md`, and the relevant `user-stories/purpur-ignis/` documents. Follow the parent workspace instructions. Do not create a local knowledge/design bundle.
|
||||
|
||||
Use Java 25 and the declared Purpur API. Test behavior first with meaningful failures, implement minimally, then run `./gradlew clean check jar`. Never mark server/client behavior verified through domain tests alone.
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Spigot Ignis
|
||||
# Purpur Ignis
|
||||
|
||||
Server-wide earned shadow progression for Social Minecraft. **Echo of the Abyss, advancement-name scrambling, The Mask, and The Dagger are implemented in source; live-server/client verification is pending.** Heart gameplay is not implemented yet.
|
||||
Server-wide earned shadow progression for Social Minecraft. **Echo of the Abyss, advancement-name scrambling, The Mask, The Dagger, and The Heart of Shadows are implemented in source; live-server/client verification is pending.**
|
||||
|
||||
The repository, checkout and new artifacts use `purpur-ignis`. Runtime `SpigotIgnis`, Java packages/entrypoint, `plugins/SpigotIgnis/state.bin`, all `spigotignis:` item/recipe/provenance keys and existing gameplay are retained. Replace the old JAR rather than installing both distributions. Old tags/assets remain available. This rename does not close known Echo consumption/recovery gaps.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -11,13 +13,13 @@ Requires Java 17+ to launch Gradle; the wrapper resolves a Java 25 toolchain for
|
||||
./gradlew clean jar -PreleaseVersion=0.1.0
|
||||
```
|
||||
|
||||
Targets Purpur `26.2.build.2618-stable` and requires **ProtocolLib 5.4.0** for Mask helmet projection. Neither server API nor ProtocolLib is bundled. Source builds do not require the private wiki.
|
||||
The runtime requires Java 25. New JARs are `build/libs/purpur-ignis-<version>.jar`. Targets Purpur `26.2.build.2618-stable` and requires **ProtocolLib 5.4.0** for Mask helmet projection. Neither server API nor ProtocolLib is bundled. Source builds do not require the private wiki.
|
||||
|
||||
## Knowledge and delivery
|
||||
|
||||
The [canonical project](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-ignis/index.md) and stories live in the shared private SoMC wiki. There is no repository-local knowledge bundle.
|
||||
The [canonical project](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-ignis/index.md) and stories live in the shared private SoMC wiki. There is no repository-local knowledge bundle.
|
||||
|
||||
CI/release workflows publish versioned source artifacts; verify their exact run/commit before using any artifact. Release publication requires the repository Actions secret `RELEASE_TOKEN`, provisioned separately. The release workflow uses a repository-scoped Git credential helper and curl stdin for authentication rather than embedding a release credential in URLs or process arguments. Semantic-release overrides `GIT_ASKPASS`, so askpass is not used for release authentication. Never enable tracing. GitOps and production release need separate approvals.
|
||||
CI/release workflows publish versioned source artifacts; verify their exact run/commit before using any artifact. Release publication requires the repository Actions secret `RELEASE_TOKEN`, provisioned separately. The release workflow uses a Git credential helper scoped exclusively to `https://git.garvis.dev/dmg/purpur-ignis.git` and curl stdin for authentication rather than embedding a release credential in URLs or process arguments. Semantic-release overrides `GIT_ASKPASS`, so askpass is not used for release authentication. Never enable tracing. GitOps and production release need separate approvals.
|
||||
|
||||
## Foundation interfaces and state
|
||||
|
||||
@@ -35,9 +37,9 @@ Take uncancelled void damage, ascend while gliding with elytra in the same world
|
||||
|
||||
The earned Echo Shard is named **Echo of the Abyss**, with cryptic lore and a UUID-bound receipt. Right-click it to activate permanent obfuscated-name advancement announcements. It does not hide general identity or alter advancement titles/descriptions. Only the bound owner may use it, and activation cannot grant twice. Lost, unconsumed rewards can be earned again by repeating the feat. Admins can also grant a bound Echo.
|
||||
|
||||
Full inventories retain a pending reward; online delivery retries every five seconds using the same receipt identity. Temporary inventory/drop/swap locks prevent ordinary moves during the short activation commit. Disk failures do not consume the item on activation; failed completion saves produce a player-facing recovery message rather than pretending the reward was saved.
|
||||
Full inventories retain a pending reward; online delivery retries every five seconds using the same receipt identity. Temporary inventory/drop/swap locks prevent ordinary moves during the short activation commit. Disk failures do not consume the item on activation, so it can be retried. Failed challenge-completion saves log the owner UUID and receipt ID for administrator recovery even if shutdown prevents the player callback; an online player is also notified when scheduling remains available. Pending delivery acknowledgements can retry without offering another copy while the original remains in the inventory.
|
||||
|
||||
Announcement handling uses Purpur's Adventure `PlayerAdvancementDoneEvent.message`, replacing only the first name argument of the three vanilla advancement translations. It preserves silent events and unrelated/custom message formats. Advancement transformation itself needs no ProtocolLib interception; the plugin's ProtocolLib dependency serves Mask equipment projection. Existing Stealth outbound masking sees the obfuscated alias rather than the original name. Live coexistence remains unverified.
|
||||
Announcement handling uses Purpur's Adventure `PlayerAdvancementDoneEvent.message`, replacing only the first name argument of the three vanilla advancement translations. It preserves silent events and unrelated/custom message formats. An advancement-only ProtocolLib output backstop also covers Purpur's affected-player-only branch, which sends the original pre-event component rather than the event's replacement. The backstop identifies an unambiguous player UUID in the name slot's entity hover and checks saved activation; it does not infer identity from name text, touch titles/descriptions or broaden recipients. Ordinary/custom chat remains unchanged. Native activation/reopen, event/delivery branches, packet codecs and plugin lifecycle tests cover this fix; the exact live report setting/format remains unconfirmed. Existing Stealth outbound masking sees the obfuscated alias rather than the original name. Live coexistence remains unverified.
|
||||
|
||||
## The Mask
|
||||
|
||||
@@ -73,11 +75,21 @@ Right-click normally attempts **three blocks along the aim direction**. A visibl
|
||||
|
||||
A valid attempt starts a **two-second per-player cooldown**, shared across copies, even if unsafe or cancelled. Further attempts do not extend it. A blocked selected destination does not fall back to a different teleport. Item-use vetoes are respected, while vanilla's default denied block interaction with air does not suppress custom item use. Authenticated keys are prevented from being consumed as vanilla vault keys, including revoked copies.
|
||||
|
||||
Safety uses the player's current bounding box, a continuous sweep against block collision shapes (including neighboring protruding shapes), a supported exit, world/build boundaries, and conservative liquid/hazard rejection. It does not sample past thin walls or load missing chunks. Work is capped at 32,768 candidate block cells; extreme body sizes/routes can be refused. Unsupported mid-air exits and campfire/hazard surfaces are conservatively refused. Native `Player.teleport(..., PLUGIN)` preserves the normal protection/cancellation event path and leaves the reusable item intact. Actual terrain, scaled/posed player collisions, and protection-plugin behavior still require live tests.
|
||||
Safety uses the player's current bounding box, a continuous sweep against block collision shapes (including neighboring protruding shapes), world/build boundaries, and conservative liquid/hazard rejection. It does not sample past thin walls or load missing chunks. Work is capped at 32,768 candidate block cells; extreme body sizes/routes can be refused. Unsupported midair exits are allowed; hazardous surfaces, including campfires, remain refused. Grounded and airborne blinks neither require nor consume hunger and add no flight or fall immunity. Native `Player.teleport(..., PLUGIN)` preserves the normal protection/cancellation event path and leaves the reusable item intact. Actual terrain, scaled/posed player collisions, and protection-plugin behavior still require live tests.
|
||||
|
||||
## Commands
|
||||
|
||||
All players may use `/ignis umbra` to privately see the three item tracks. Mask and Dagger counters are active; Heart natural progression and its item remain unimplemented, though administrator-seeded values can be inspected. No passive progress UI is added.
|
||||
All players may use `/ignis umbra` to privately see the three active item tracks. No passive progress UI is added.
|
||||
|
||||
### The Heart of Shadows
|
||||
|
||||
Earn the personal unlock through six uninterrupted online hours at total light zero (configurable). Uncancelled positive final damage, leaving darkness, disconnecting, or restarting interrupts unfinished progress. Completed unlocks persist until administrative reset. Light observation never loads chunks; unavailable lighting cannot qualify.
|
||||
|
||||
Craft a Creaking Heart surrounded orthogonally by four Ominous Bottle V items, with four Lingering Potions of Instant Health II in the corners. The registered recipe uses exact vanilla potion/bottle metadata, and preparation/click checks independently validate the matrix. Automated Crafters are denied; issued Hearts, including revoked copies, cannot be placed as blocks.
|
||||
|
||||
Carrying an authenticated Heart requires both the holder's unlock and a valid issuer receipt/generation. It grants Regeneration II at total light zero, or at night in natural skylit worlds. Multiple copies do not stack. Existing stronger regeneration is left alone; weaker regeneration overlaid by Ignis is restored with its remaining server-tick duration on withdrawal. External clears/replacements and cancellation take precedence. Cleanup and native potion-event ordering still require live verification.
|
||||
|
||||
Heart counters checkpoint periodically off-thread. Startup durably clears unfinished streaks before enabling gameplay; shutdown drains interruption writes before closing persistence. Administrator progress edits flush and rebase the active interval rather than replaying older elapsed progress.
|
||||
|
||||
Administration requires **`ignis.admin`** (default: operators), checked independently during execution and autocomplete:
|
||||
|
||||
@@ -87,11 +99,13 @@ Administration requires **`ignis.admin`** (default: operators), checked independ
|
||||
/ignisadmin limits set <mask-ms|pearls|darkness-ms> <positive-value>
|
||||
/ignisadmin progress <set|add> <online-name|known-UUID> <mask|dagger|heart> <nonnegative-value>
|
||||
/ignisadmin complete <online-name|known-UUID> void
|
||||
/ignisadmin give <online-name|known-UUID> <echo|mask|dagger>
|
||||
/ignisadmin give <online-name|known-UUID> <echo|mask|dagger|heart>
|
||||
/ignisadmin reset <online-name|known-UUID> <void|mask|dagger|heart|all>
|
||||
/ignisadmin reset <online-name|known-UUID> <void|mask|dagger|heart|all> confirm
|
||||
```
|
||||
|
||||
`inspect` reports saved progress for all four tracks (Mask/Heart milliseconds and Dagger pearls), pending/issued reward counts, recipe unlock eligibility, and Echo eligibility separately from permanent advancement-name-scrambling activation. It also reports the active void attempt: `awaiting-elytra-ascent`, `awaiting-end-stone`, or `none`, including world UUID and original damage height when active. Inspection is read-only: it does not checkpoint progress, advance/cancel challenges, deliver items or write state. Counters exclude uncommitted tracker time; recipe unlocks do not mean item effects are currently equipped or active. Known offline UUIDs remain inspectable; unfinished void attempts are not restored after disconnect/restart.
|
||||
|
||||
Reset confirmation must match the same sender, target, and tracks within 30 seconds; `confirm` alone cannot bypass the initial request. Reset clears the selected progress/unlocks/activation, pending rewards and issuance receipts, and advances generation, making old Echo copies invalid even after re-earning. Other tracks are unchanged by a single-track reset. Echo grants unlock eligibility but still require consumption to activate scrambling; already activated players do not gain another activation. Admin Mask/Dagger grants unlock use and queue authenticated items for inventory-ready delivery; new Masks also require profile resolution. Mask/Dagger delivery retries remember an already offered gift while its acknowledgement save is failing, so trading it away does not cause another offer in the same plugin session. This marker is not durable across process restart; failed-acknowledgement/restart recovery still requires operator review. Admin mutations are audited, not routine private progress reads.
|
||||
|
||||
Offline targets use UUIDs already present in Ignis state; online exact names are resolved without network profile lookups. Commands and contextual completion do not claim that unimplemented reward items are available.
|
||||
@@ -102,12 +116,16 @@ Offline targets use UUIDs already present in Ignis state; online exact names are
|
||||
|
||||
Writes use a same-directory temporary file, flush it to disk, and require atomic replacement. Unsupported atomic moves or write failures retain the prior active snapshot and fail the mutation; there is no non-atomic fallback. Abandoned temporary files are ignored, never auto-promoted. This does not guarantee survival of arbitrary storage hardware or power failures.
|
||||
|
||||
For a reported failed Echo award, restore writable storage first and match the logged owner UUID/receipt to the player. Inspect saved activation and pending rewards before granting anything: pending rewards retry automatically when inventory space is available. If the failed award needs replacement, use the existing operator-only `/ignisadmin give <target> echo` after review. A UUID not yet saved in Ignis must reconnect so its exact online name can be targeted. Do not reset earned progression merely to remove an inert leftover item, and do not grant a second reward for an already activated benefit.
|
||||
|
||||
If loading fails, preserve the original file for investigation. Under an approved maintenance plan, stop the server, back up current data, restore a verified matching-version backup, and restart. Do not delete state to recover: that loses earned progress and invalidation records. Do not hand-edit the binary file or import another plugin's state. Restoring an old backup can also restore older revocation/progress state; review that consequence before doing it.
|
||||
|
||||
## Verification limits
|
||||
|
||||
JUnit tests exercise real temporary-file persistence, serialized writes, fault injection, threshold policies, receipt/owner/reset rules, challenge transitions, announcement components, command execution/completion, mocked Bukkit boundaries, metadata, and static workflow guardrails. YAML/shell parsing is not remote CI verification.
|
||||
|
||||
Live Purpur startup/shutdown, event sequencing, item appearance/consumption, inventory durability across hard crashes, protections, and Stealth/client compatibility still require a manual smoke test. Minecraft inventory persistence and this plugin's state file are not a single atomic transaction: a hard crash between item delivery and Minecraft saving inventory needs operator recovery review. If disconnect/death happens during activation, the durable benefit can commit before the physical item is removed; any retained copy is inert, not another usable unlock. These limitations prevent claiming complete live/crash acceptance or deploying automatically.
|
||||
Native Dagger regressions exercise paid result-slot crafting, registered events, incoming use-item packet handling and CraftPlayer/native teleport state with outgoing position packets. They cover grounded and unsupported-air forward/target blinks with empty hunger, item retention, safety/protection refusals, receipts/resets and failed/cancelled cooldowns. The finite world, session/connection and visibility boundaries are fixtures, not a live server/client diagnosis.
|
||||
|
||||
Before a later approved production release, test: void damage/elytra/End Stone success and all interruption paths; other players and lookalikes failing activation; full inventory and failed saves; logout/death during activation; reset/re-earn invalidation; all advancement types and hover/click content with Stealth; admin permission/confirmation/offline UUID cases; direct-drink attribution after reconnect/restart and external effect replacement; skin outages; manual/shift/Crafter ingredient protection; tradable Mask issuer/holder resets; own/other-player helmet rendering, inventory interactions, and preservation of external invisibility; real consumed-pearl launch/statistic ordering; Dagger regular/shift/Crafter recipes, stale receipts, vault-key protection, target visibility, thin obstacles, scaled/posed bodies, hazardous/unsupported exits, world/chunk boundaries, cooldowns across copies/disconnect, and cancelled or redirected protection-plugin teleports.
|
||||
Live Purpur startup/shutdown, event sequencing, item appearance/consumption, inventory durability across hard crashes, protections, and Stealth/client compatibility still require a manual smoke test. Minecraft inventory persistence and this plugin's state file are not a single atomic transaction: a hard crash between item delivery and Minecraft saving inventory needs operator recovery review. If disconnect/death happens during activation, the durable benefit can commit before the physical item is removed; any retained copy is inert, not another usable unlock. The approved lightweight contract guarantees one-item consumption in uninterrupted play and one permanent owner activation, not crash-atomic physical consumption. Hard crashes between the two saves may require administrator recovery; extra copies left by interruptions cannot activate again. No cross-save transaction protocol, forced player saves or autosave-length item restrictions are added. This is not a claim of live verification or permission to deploy.
|
||||
|
||||
Before a later approved production release, test: void damage/elytra/End Stone success and all interruption paths; other players and lookalikes failing activation; full inventory and failed saves; logout/death during activation; reset/re-earn invalidation; all advancement types and hover/click content with Stealth; admin permission/confirmation/offline UUID cases; direct-drink attribution after reconnect/restart and external effect replacement; skin outages; manual/shift/Crafter ingredient protection; tradable Mask issuer/holder resets; own/other-player helmet rendering, inventory interactions, and preservation of external invisibility; real consumed-pearl launch/statistic ordering; Dagger regular/shift/Crafter recipes, stale receipts, vault-key protection, target visibility, thin obstacles, scaled/posed bodies, hazard refusals and allowed unsupported exits, world/chunk boundaries, cooldowns across copies/disconnect, and cancelled or redirected protection-plugin teleports.
|
||||
+59
-1
@@ -1,3 +1,6 @@
|
||||
import java.net.URI
|
||||
import java.security.MessageDigest
|
||||
|
||||
plugins { java }
|
||||
|
||||
group = "games.dmg"
|
||||
@@ -24,7 +27,62 @@ dependencies {
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
|
||||
tasks.test { useJUnitPlatform() }
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
dependsOn(tasks.jar)
|
||||
systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath)
|
||||
systemProperty("distribution.version", project.version.toString())
|
||||
}
|
||||
// Native API/event/delivery regressions run without starting a listening server.
|
||||
val ignisRuntime = layout.buildDirectory.dir("ignis-runtime")
|
||||
val downloadIgnisRuntime = tasks.register("downloadIgnisRuntime") {
|
||||
val launcher = ignisRuntime.map { it.file("purpur-26.2-2618.jar") }
|
||||
outputs.file(launcher)
|
||||
doLast {
|
||||
val file = launcher.get().asFile
|
||||
file.parentFile.mkdirs()
|
||||
val connection = URI("https://api.purpurmc.org/v2/purpur/26.2/2618/download").toURL().openConnection()
|
||||
connection.connectTimeout = 30_000
|
||||
connection.readTimeout = 120_000
|
||||
val bytes = connection.getInputStream().use { it.readBytes() }
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
|
||||
check(digest == "4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31") { "Purpur 2618 checksum mismatch" }
|
||||
file.writeBytes(bytes)
|
||||
}
|
||||
}
|
||||
val prepareIgnisRuntime = tasks.register<JavaExec>("prepareIgnisRuntime") {
|
||||
dependsOn(downloadIgnisRuntime)
|
||||
javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) }
|
||||
classpath = files(ignisRuntime.map { it.file("purpur-26.2-2618.jar") })
|
||||
mainClass = "io.papermc.paperclip.Main"
|
||||
jvmArgs("-Dpaperclip.patchonly=true")
|
||||
workingDir(ignisRuntime)
|
||||
outputs.dir(ignisRuntime.map { it.dir("versions") })
|
||||
outputs.dir(ignisRuntime.map { it.dir("libraries") })
|
||||
}
|
||||
val nativeTest = sourceSets.create("nativeTest")
|
||||
dependencies {
|
||||
add(nativeTest.implementationConfigurationName, platform("org.junit:junit-bom:5.13.4"))
|
||||
add(nativeTest.implementationConfigurationName, "org.junit.jupiter:junit-jupiter")
|
||||
add(nativeTest.implementationConfigurationName, "org.mockito:mockito-core:5.18.0")
|
||||
add(nativeTest.implementationConfigurationName, "net.dmulloy2:ProtocolLib:5.4.0")
|
||||
add(nativeTest.compileOnlyConfigurationName, "org.jetbrains:annotations:26.0.2")
|
||||
add(nativeTest.compileOnlyConfigurationName, "org.checkerframework:checker-qual:3.49.2")
|
||||
add(nativeTest.runtimeOnlyConfigurationName, "org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
val nativeRuntimeJars = files(fileTree(ignisRuntime) { include("versions/**/*.jar", "libraries/**/*.jar") }).builtBy(prepareIgnisRuntime)
|
||||
nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars
|
||||
nativeTest.runtimeClasspath += sourceSets.main.get().output + nativeRuntimeJars
|
||||
val nativeIgnisTest = tasks.register<Test>("nativeIgnisTest") {
|
||||
description = "Runs native Purpur regressions without starting a server"
|
||||
testClassesDirs = nativeTest.output.classesDirs
|
||||
classpath = nativeTest.runtimeClasspath
|
||||
useJUnitPlatform()
|
||||
maxHeapSize = "1G"
|
||||
workingDir(ignisRuntime)
|
||||
}
|
||||
tasks.check { dependsOn(nativeIgnisTest) }
|
||||
|
||||
val pluginVersion = version
|
||||
tasks.processResources {
|
||||
filesMatching("plugin.yml") { expand("version" to pluginVersion) }
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" }
|
||||
rootProject.name = "spigot-ignis"
|
||||
rootProject.name = "purpur-ignis"
|
||||
@@ -14,11 +14,33 @@ public final class AdvancementMasker {
|
||||
private AdvancementMasker() { }
|
||||
|
||||
public static Component mask(Component original) {
|
||||
return maskNameSlots(original, name -> true);
|
||||
}
|
||||
|
||||
/** Packet-only fallback requires a single authenticated player UUID in the name slot, never a text-name guess. */
|
||||
public static Component maskIdentified(Component original, java.util.function.Predicate<java.util.UUID> activated) {
|
||||
return maskNameSlots(original, name -> {
|
||||
var identities = new java.util.HashSet<java.util.UUID>();
|
||||
collectIdentities(name, identities);
|
||||
return identities.size() == 1 && activated.test(identities.iterator().next());
|
||||
});
|
||||
}
|
||||
|
||||
private static void collectIdentities(Component component, java.util.Set<java.util.UUID> identities) {
|
||||
if (component.hoverEvent() != null && component.hoverEvent().value() instanceof net.kyori.adventure.text.event.HoverEvent.ShowEntity entity
|
||||
&& entity.type().equals(net.kyori.adventure.key.Key.key("minecraft:player"))) {
|
||||
identities.add(entity.id());
|
||||
}
|
||||
for (var child : component.children()) { collectIdentities(child, identities); }
|
||||
}
|
||||
|
||||
private static Component maskNameSlots(Component original, java.util.function.Predicate<Component> authorized) {
|
||||
if (original == null) return null;
|
||||
Component result = original;
|
||||
boolean changed = false;
|
||||
if (result instanceof TranslatableComponent translation && KEYS.contains(translation.key())
|
||||
&& !translation.arguments().isEmpty()) {
|
||||
&& !translation.arguments().isEmpty()
|
||||
&& translation.arguments().getFirst().value() instanceof Component name && authorized.test(name)) {
|
||||
var arguments = new ArrayList<ComponentLike>(translation.arguments());
|
||||
arguments.set(0, Component.text("Anonymous").decorate(TextDecoration.OBFUSCATED).insertion(""));
|
||||
result = translation.arguments(arguments);
|
||||
@@ -26,7 +48,7 @@ public final class AdvancementMasker {
|
||||
}
|
||||
var children = new ArrayList<Component>();
|
||||
for (Component child : original.children()) {
|
||||
Component masked = mask(child);
|
||||
Component masked = maskNameSlots(child, authorized);
|
||||
children.add(masked);
|
||||
changed |= masked != child;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.events.ListenerPriority;
|
||||
import com.comphenix.protocol.events.PacketAdapter;
|
||||
import com.comphenix.protocol.events.PacketEvent;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Predicate;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/** Advancement-only wire backstop for native delivery paths which do not use the event's replacement component. */
|
||||
public final class AnnouncementPacketListener extends PacketAdapter {
|
||||
private final Predicate<UUID> activated;
|
||||
private final Consumer<String> warning;
|
||||
private final java.lang.reflect.Method content, overlay, asAdventure, asVanilla;
|
||||
private final java.lang.reflect.Constructor<?> packet;
|
||||
private final java.util.concurrent.atomic.AtomicBoolean warned = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
|
||||
public AnnouncementPacketListener(Plugin plugin, Predicate<UUID> activated, Consumer<String> warning) {
|
||||
super(plugin, ListenerPriority.HIGHEST, PacketType.Play.Server.SYSTEM_CHAT);
|
||||
this.activated = java.util.Objects.requireNonNull(activated);
|
||||
this.warning = java.util.Objects.requireNonNull(warning);
|
||||
try {
|
||||
var nativeComponent = Class.forName("net.minecraft.network.chat.Component");
|
||||
var packetType = Class.forName("net.minecraft.network.protocol.game.ClientboundSystemChatPacket");
|
||||
var adventure = Class.forName("io.papermc.paper.adventure.PaperAdventure");
|
||||
content = packetType.getMethod("content");
|
||||
overlay = packetType.getMethod("overlay");
|
||||
asAdventure = adventure.getMethod("asAdventure", nativeComponent);
|
||||
asVanilla = adventure.getMethod("asVanilla", net.kyori.adventure.text.Component.class);
|
||||
packet = packetType.getConstructor(nativeComponent, boolean.class);
|
||||
} catch (ReflectiveOperationException failure) { throw new IllegalStateException("Unsupported native announcement packet layout", failure); }
|
||||
}
|
||||
|
||||
@Override public void onPacketSending(PacketEvent event) {
|
||||
if (event.isCancelled()) { return; }
|
||||
try {
|
||||
Object originalPacket = event.getPacket().getHandle();
|
||||
if ((boolean) overlay.invoke(originalPacket)) { return; }
|
||||
var original = (net.kyori.adventure.text.Component) asAdventure.invoke(null, content.invoke(originalPacket));
|
||||
var masked = AdvancementMasker.maskIdentified(original, activated);
|
||||
if (masked != original) {
|
||||
Object projected = packet.newInstance(asVanilla.invoke(null, masked), false);
|
||||
event.setPacket(new com.comphenix.protocol.events.PacketContainer(PacketType.Play.Server.SYSTEM_CHAT, projected));
|
||||
}
|
||||
} catch (ReflectiveOperationException | RuntimeException failure) {
|
||||
if (warned.compareAndSet(false, true)) { warning.accept("Ignis announcement projection unavailable; original delivery retained: " + failure.getClass().getSimpleName()); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,121 +1,28 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.CraftItemEvent;
|
||||
import org.bukkit.event.block.CrafterCraftEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
public final class DaggerCrafting implements Listener, Runnable {
|
||||
public final class DaggerCrafting extends ReceiptCrafting {
|
||||
public static final NamespacedKey RECIPE = new NamespacedKey("spigotignis", "dagger");
|
||||
private static final TradableRewards REWARDS = new TradableRewards(Track.DAGGER);
|
||||
private final JavaPlugin plugin;
|
||||
private final StateService states;
|
||||
private final DaggerItems items;
|
||||
private final EchoGameplay dispatch;
|
||||
private final java.util.function.Function<org.bukkit.inventory.ItemStack, org.bukkit.inventory.ShapedRecipe> recipeFactory;
|
||||
public DaggerCrafting(JavaPlugin plugin, StateService states, DaggerItems items, EchoGameplay dispatch) {
|
||||
this(plugin, states, items, dispatch, result -> new org.bukkit.inventory.ShapedRecipe(RECIPE, result));
|
||||
this(plugin, states, items, dispatch, result -> new ShapedRecipe(RECIPE, result));
|
||||
}
|
||||
DaggerCrafting(JavaPlugin plugin, StateService states, DaggerItems items, EchoGameplay dispatch,
|
||||
java.util.function.Function<org.bukkit.inventory.ItemStack, org.bukkit.inventory.ShapedRecipe> recipeFactory) {
|
||||
this.plugin = plugin; this.states = states; this.items = items; this.dispatch = dispatch;
|
||||
this.recipeFactory = recipeFactory;
|
||||
Function<ItemStack, ShapedRecipe> recipeFactory) {
|
||||
super(plugin, states, items, dispatch, Track.DAGGER, RECIPE, recipeFactory);
|
||||
}
|
||||
private boolean registered;
|
||||
private final java.util.Set<java.util.UUID> preparing = new java.util.HashSet<>();
|
||||
private final java.util.Set<ItemToken> delivering = new java.util.HashSet<>();
|
||||
private final java.util.Set<ItemToken> offered = new java.util.HashSet<>();
|
||||
@Override public void run() {
|
||||
if (!registered) {
|
||||
var recipe = recipeFactory.apply(items.prototype());
|
||||
configureRecipe(recipe);
|
||||
registered = plugin.getServer().addRecipe(recipe);
|
||||
if (!registered) return;
|
||||
}
|
||||
for (var player : plugin.getServer().getOnlinePlayers()) {
|
||||
var state = states.snapshot().players().getOrDefault(player.getUniqueId(), PlayerState.empty());
|
||||
if (!state.track(Track.DAGGER).unlocked()) {
|
||||
if (player.hasDiscoveredRecipe(RECIPE)) player.undiscoverRecipe(RECIPE);
|
||||
continue;
|
||||
}
|
||||
ensureReceipt(player);
|
||||
if (!player.hasDiscoveredRecipe(RECIPE)) player.discoverRecipe(RECIPE);
|
||||
for (var entry : state.pendingRewards().entrySet()) {
|
||||
if (entry.getValue() != Track.DAGGER) continue;
|
||||
var token = new ItemToken(player.getUniqueId(), entry.getKey(), state.track(Track.DAGGER).generation());
|
||||
if (delivering.contains(token)) continue;
|
||||
if (!offered.contains(token) && !items.offer(player, token)) continue;
|
||||
offered.add(token);
|
||||
delivering.add(token);
|
||||
states.update(current -> REWARDS.acknowledge(current, token))
|
||||
.whenComplete((ignored, error) -> dispatch.onServer(() -> {
|
||||
delivering.remove(token);
|
||||
if (error == null) offered.remove(token);
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
private void ensureReceipt(org.bukkit.entity.Player player) {
|
||||
var owner = player.getUniqueId();
|
||||
var snapshot = states.snapshot();
|
||||
var state = snapshot.players().getOrDefault(owner, PlayerState.empty()).track(Track.DAGGER);
|
||||
if (!state.unlocked() || REWARDS.craftingToken(snapshot, owner).isPresent() || !preparing.add(owner)) return;
|
||||
var receipt = java.util.UUID.randomUUID();
|
||||
long generation = state.generation();
|
||||
states.update(current -> {
|
||||
var now = current.players().getOrDefault(owner, PlayerState.empty()).track(Track.DAGGER);
|
||||
return now.unlocked() && now.generation() == generation ? REWARDS.issue(current, owner, receipt, false) : current;
|
||||
}).whenComplete((ignored, error) -> dispatch.onServer(() -> {
|
||||
preparing.remove(owner);
|
||||
var online = plugin.getServer().getPlayer(owner);
|
||||
if (error != null || online == null) return;
|
||||
var view = online.getOpenInventory();
|
||||
if (view.getTopInventory() instanceof org.bukkit.inventory.CraftingInventory inventory && matches(inventory.getRecipe())) {
|
||||
inventory.setResult(items.prototype());
|
||||
plugin.getServer().getPluginManager().callEvent(new org.bukkit.event.inventory.PrepareItemCraftEvent(inventory, view, false));
|
||||
online.updateInventory();
|
||||
}
|
||||
}));
|
||||
}
|
||||
public void close() { if (registered) plugin.getServer().removeRecipe(RECIPE); }
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onCraft(CraftItemEvent event) {
|
||||
if (!matches(event.getRecipe())) return;
|
||||
var identity = items.identify(event.getInventory().getResult());
|
||||
if (!(event.getWhoClicked() instanceof org.bukkit.entity.Player player) || identity.isEmpty()
|
||||
|| !identity.orElseThrow().issuer().equals(player.getUniqueId())
|
||||
|| !REWARDS.valid(states.snapshot(), player.getUniqueId(), identity.orElseThrow())) {
|
||||
event.setCancelled(true);
|
||||
event.getInventory().setResult(null);
|
||||
}
|
||||
}
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onCrafter(CrafterCraftEvent event) {
|
||||
if (matches(event.getRecipe())) event.setCancelled(true);
|
||||
}
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
|
||||
public void onPrepare(org.bukkit.event.inventory.PrepareItemCraftEvent event) {
|
||||
if (!matches(event.getInventory().getRecipe()) || event.getInventory().getResult() == null) return;
|
||||
if (!(event.getView().getPlayer() instanceof org.bukkit.entity.Player player)) {
|
||||
event.getInventory().setResult(null);
|
||||
return;
|
||||
}
|
||||
var token = REWARDS.craftingToken(states.snapshot(), player.getUniqueId());
|
||||
event.getInventory().setResult(token.map(items::create).orElse(null));
|
||||
if (token.isEmpty()) ensureReceipt(player);
|
||||
}
|
||||
|
||||
static void configureRecipe(org.bukkit.inventory.ShapedRecipe recipe) {
|
||||
@Override protected void configure(ShapedRecipe recipe) { configureRecipe(recipe); }
|
||||
static void configureRecipe(ShapedRecipe recipe) {
|
||||
recipe.shape("HCH", "NKN", "HEH");
|
||||
recipe.setIngredient('H', org.bukkit.Material.HEART_OF_THE_SEA);
|
||||
recipe.setIngredient('C', org.bukkit.Material.HEAVY_CORE);
|
||||
recipe.setIngredient('N', org.bukkit.Material.NETHER_STAR);
|
||||
recipe.setIngredient('K', org.bukkit.Material.OMINOUS_TRIAL_KEY);
|
||||
recipe.setIngredient('E', org.bukkit.Material.DRAGON_EGG);
|
||||
}
|
||||
|
||||
private static boolean matches(org.bukkit.inventory.Recipe recipe) {
|
||||
return recipe instanceof org.bukkit.Keyed keyed && keyed.getKey().equals(RECIPE);
|
||||
recipe.setIngredient('H', Material.HEART_OF_THE_SEA);
|
||||
recipe.setIngredient('C', Material.HEAVY_CORE);
|
||||
recipe.setIngredient('N', Material.NETHER_STAR);
|
||||
recipe.setIngredient('K', Material.OMINOUS_TRIAL_KEY);
|
||||
recipe.setIngredient('E', Material.DRAGON_EGG);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,12 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
public final class DaggerItems {
|
||||
private final java.util.function.Supplier<ItemStack> factory;
|
||||
public DaggerItems() { this(() -> new ItemStack(org.bukkit.Material.OMINOUS_TRIAL_KEY)); }
|
||||
DaggerItems(java.util.function.Supplier<ItemStack> factory) { this.factory = factory; }
|
||||
public ItemStack prototype() {
|
||||
var item = factory.get();
|
||||
var meta = item.getItemMeta();
|
||||
meta.displayName(net.kyori.adventure.text.Component.text("The Dagger", net.kyori.adventure.text.format.NamedTextColor.DARK_PURPLE));
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
public ItemStack create(ItemToken token) {
|
||||
var item = prototype();
|
||||
var meta = item.getItemMeta();
|
||||
meta.getPersistentDataContainer().set(IDENTITY, org.bukkit.persistence.PersistentDataType.STRING,
|
||||
EchoIdentity.encode(new EchoToken(token.issuer(), token.receipt(), token.generation())));
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static final org.bukkit.NamespacedKey IDENTITY = new org.bukkit.NamespacedKey("spigotignis", "dagger_identity");
|
||||
public boolean offer(org.bukkit.entity.Player player, ItemToken token) {
|
||||
for (var item : player.getInventory().getContents()) {
|
||||
if (identify(item).filter(token::equals).isPresent()) return true;
|
||||
}
|
||||
if (identify(player.getItemOnCursor()).filter(token::equals).isPresent()) return true;
|
||||
if (player.getInventory().firstEmpty() < 0) return false;
|
||||
return player.getInventory().addItem(create(token)).isEmpty();
|
||||
}
|
||||
|
||||
public Optional<ItemToken> identify(ItemStack item) {
|
||||
if (item == null || item.getType() != org.bukkit.Material.OMINOUS_TRIAL_KEY || !item.hasItemMeta()) return Optional.empty();
|
||||
return EchoIdentity.decode(item.getItemMeta().getPersistentDataContainer().get(IDENTITY, org.bukkit.persistence.PersistentDataType.STRING))
|
||||
.map(token -> new ItemToken(token.owner(), token.receipt(), token.generation()));
|
||||
public final class DaggerItems extends ReceiptItems {
|
||||
public DaggerItems() { this(() -> new ItemStack(Material.OMINOUS_TRIAL_KEY)); }
|
||||
DaggerItems(Supplier<ItemStack> factory) {
|
||||
super(Material.OMINOUS_TRIAL_KEY, "dagger_identity", "The Dagger", factory);
|
||||
}
|
||||
}
|
||||
@@ -38,21 +38,22 @@ public final class DaggerSafety {
|
||||
if (!world.isChunkLoaded(x, z)) return false;
|
||||
}
|
||||
}
|
||||
var supportedBody = end.clone().shift(0, -.05, 0);
|
||||
// Keep rejecting hazardous surfaces just beneath the exit, but do not require ground.
|
||||
var nearFeetBody = end.clone().shift(0, -.05, 0);
|
||||
for (int x = minX; x <= maxX; x++) {
|
||||
for (int y = minY; y <= maxY; y++) {
|
||||
for (int z = minZ; z <= maxZ; z++) {
|
||||
var block = world.getBlockAt(x, y, z);
|
||||
var cell = new BoundingBox(x, y, z, x + 1, y + 1, z + 1);
|
||||
if ((block.isLiquid() || dangerous(block.getType()))
|
||||
&& (DaggerSweep.hits(body, motion, cell) || supportedBody.overlaps(cell))) return false;
|
||||
&& (DaggerSweep.hits(body, motion, cell) || nearFeetBody.overlaps(cell))) return false;
|
||||
for (var local : block.getCollisionShape().getBoundingBoxes()) {
|
||||
if (DaggerSweep.hits(body, motion, local.clone().shift(x, y, z))) return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return player.wouldCollideUsing(supportedBody);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean dangerous(Material material) {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/** Session identity prevents interrupted intervals from being replayed by queued writes. */
|
||||
public final class DarknessProgress {
|
||||
private final StateService states;
|
||||
private final LongSupplier clock;
|
||||
private final Map<UUID, Session> sessions = new HashMap<>();
|
||||
private static final class Session {
|
||||
final long generation;
|
||||
final DarknessStreak streak;
|
||||
DarknessStreak.Progress desired;
|
||||
boolean active;
|
||||
Session(long generation, long seed, LongSupplier clock) {
|
||||
this.generation = generation;
|
||||
streak = new DarknessStreak(clock, seed);
|
||||
desired = new DarknessStreak.Progress(seed, false);
|
||||
}
|
||||
}
|
||||
public DarknessProgress(StateService states, LongSupplier clock) { this.states = states; this.clock = clock; }
|
||||
private Session session(UUID player) {
|
||||
var track = states.snapshot().players().getOrDefault(player, PlayerState.empty()).track(Track.HEART);
|
||||
return sessions.compute(player, (ignored, old) -> old == null || old.generation != track.generation()
|
||||
? new Session(track.generation(), track.progress(), clock) : old);
|
||||
}
|
||||
public synchronized void observe(UUID player, boolean dark) {
|
||||
if (!dark) { interrupt(player); return; }
|
||||
Session session = session(player);
|
||||
session.active = true;
|
||||
session.desired = session.streak.sample(true, states.snapshot().limits().darknessMillis());
|
||||
}
|
||||
public synchronized void interrupt(UUID player) {
|
||||
Session session = session(player);
|
||||
if (session.desired.complete()) return;
|
||||
if (session.active || session.desired.millis() != 0) {
|
||||
sessions.put(player, new Session(session.generation, 0, clock));
|
||||
}
|
||||
}
|
||||
public synchronized CompletableFuture<IgnisState> edit(UUID player, long value, boolean add) {
|
||||
Session before = session(player);
|
||||
return checkpoint(player).thenCompose(ignored -> states.update(state ->
|
||||
ProgressAdministration.progress(state, player, Track.HEART, value, add))).thenApply(state -> {
|
||||
synchronized (this) { sessions.remove(player, before); }
|
||||
return state;
|
||||
});
|
||||
}
|
||||
private record Capture(Session session, DarknessStreak.Progress desired) { }
|
||||
private CompletableFuture<Void> batch;
|
||||
public synchronized CompletableFuture<Void> checkpointAll() {
|
||||
if (batch == null || batch.isDone()) batch = saveBatch();
|
||||
return batch;
|
||||
}
|
||||
public synchronized CompletableFuture<Void> stopAll() {
|
||||
new java.util.ArrayList<>(sessions.keySet()).forEach(this::interrupt);
|
||||
// Shutdown must persist the new interruptions even if an older batch is in flight.
|
||||
batch = saveBatch();
|
||||
return batch;
|
||||
}
|
||||
private CompletableFuture<Void> saveBatch() {
|
||||
var captured = new HashMap<UUID, Capture>();
|
||||
sessions.forEach((id, session) -> captured.put(id, new Capture(session, session.desired)));
|
||||
if (captured.isEmpty()) return CompletableFuture.completedFuture(null);
|
||||
return states.update(state -> {
|
||||
var players = new HashMap<>(state.players());
|
||||
captured.forEach((id, capture) -> {
|
||||
var current = players.getOrDefault(id, PlayerState.empty());
|
||||
var heart = current.track(Track.HEART);
|
||||
synchronized (this) {
|
||||
if (sessions.get(id) != capture.session() || heart.generation() != capture.session().generation || heart.unlocked()) return;
|
||||
}
|
||||
var tracks = new HashMap<>(current.tracks());
|
||||
tracks.put(Track.HEART, new TrackState(capture.desired().millis(), capture.desired().complete(), heart.activated(), heart.generation()));
|
||||
players.put(id, new PlayerState(tracks, current.pendingRewards(), current.issuedRewards()));
|
||||
});
|
||||
return new IgnisState(state.limits(), players).evaluate(state.limits());
|
||||
}).thenApply(ignored -> null);
|
||||
}
|
||||
|
||||
public synchronized CompletableFuture<IgnisState> checkpoint(UUID player) {
|
||||
Session session = session(player);
|
||||
var desired = session.desired;
|
||||
return states.update(state -> {
|
||||
var current = state.players().getOrDefault(player, PlayerState.empty());
|
||||
var heart = current.track(Track.HEART);
|
||||
synchronized (this) {
|
||||
if (sessions.get(player) != session || heart.generation() != session.generation || heart.unlocked()) return state;
|
||||
}
|
||||
var tracks = new HashMap<>(current.tracks());
|
||||
tracks.put(Track.HEART, new TrackState(desired.millis(), desired.complete(), heart.activated(), heart.generation()));
|
||||
var players = new HashMap<>(state.players());
|
||||
players.put(player, new PlayerState(tracks, current.pendingRewards(), current.issuedRewards()));
|
||||
return new IgnisState(state.limits(), players).evaluate(state.limits());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/** Online-session clock only; persistence and environmental observations are separate adapters. */
|
||||
public final class DarknessStreak {
|
||||
public record Progress(long millis, boolean complete) { }
|
||||
private final LongSupplier clock;
|
||||
private boolean active;
|
||||
private long started;
|
||||
private Progress progress = new Progress(0, false);
|
||||
public DarknessStreak(LongSupplier clock) { this.clock = clock; }
|
||||
private long baseMillis;
|
||||
public DarknessStreak(LongSupplier clock, long seedMillis) {
|
||||
this(clock);
|
||||
if (seedMillis < 0) throw new IllegalArgumentException("Negative streak seed.");
|
||||
baseMillis = seedMillis;
|
||||
progress = new Progress(seedMillis, false);
|
||||
}
|
||||
public Progress sample(boolean dark, long requiredMillis) {
|
||||
if (requiredMillis <= 0) throw new IllegalArgumentException("Darkness limit must be positive.");
|
||||
if (progress.complete()) return progress;
|
||||
if (!dark) return interrupt();
|
||||
long now = clock.getAsLong();
|
||||
if (!active) { started = now; active = true; }
|
||||
long elapsed = Math.max(progress.millis(), Math.addExact(baseMillis, Math.max(0, now - started) / 1_000_000));
|
||||
progress = new Progress(elapsed, elapsed >= requiredMillis);
|
||||
return progress;
|
||||
}
|
||||
public Progress interrupt() {
|
||||
active = false;
|
||||
if (!progress.complete()) {
|
||||
baseMillis = 0;
|
||||
progress = new Progress(0, false);
|
||||
}
|
||||
return progress;
|
||||
}
|
||||
}
|
||||
@@ -60,13 +60,19 @@ public final class EchoGameplay implements Listener {
|
||||
UUID owner = player.getUniqueId(), receipt = UUID.randomUUID();
|
||||
long generation = states.snapshot().players().getOrDefault(owner, PlayerState.empty()).track(Track.VOID).generation();
|
||||
states.update(state -> state.players().getOrDefault(owner, PlayerState.empty()).track(Track.VOID).generation() == generation
|
||||
? EchoRewards.award(state, owner, receipt) : state).whenComplete((state, error) -> onServer(() -> {
|
||||
? EchoRewards.award(state, owner, receipt) : state).whenComplete((state, error) -> {
|
||||
// Report independently of the tick callback: disable can prevent its delivery.
|
||||
if (error != null) {
|
||||
plugin.getLogger().warning("Echo reward save failed; administrator recovery is required.");
|
||||
Player online = plugin.getServer().getPlayer(owner);
|
||||
if (online != null) online.sendMessage("Your void reward could not be saved. Please contact an administrator.");
|
||||
} else retryDeliveries();
|
||||
}));
|
||||
plugin.getLogger().warning("Echo reward save failed for " + owner + " (receipt " + receipt
|
||||
+ "); administrator recovery is required.");
|
||||
}
|
||||
onServer(() -> {
|
||||
if (error != null) {
|
||||
Player online = plugin.getServer().getPlayer(owner);
|
||||
if (online != null) online.sendMessage("Your void reward could not be saved. Please contact an administrator.");
|
||||
} else retryDeliveries();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +82,11 @@ public final class EchoGameplay implements Listener {
|
||||
@EventHandler public void onQuit(PlayerQuitEvent event) { cancel(event.getPlayer().getUniqueId()); }
|
||||
public void cancel(UUID player) { trial.cancel(player); }
|
||||
|
||||
/** Read-only server-thread inspection; does not advance, cancel or persist the attempt. */
|
||||
public java.util.Optional<VoidTrial.Snapshot> trialSnapshot(UUID player) {
|
||||
return trial.snapshot(player);
|
||||
}
|
||||
|
||||
public boolean activated(UUID player) {
|
||||
return states.snapshot().players().getOrDefault(player, PlayerState.empty()).track(Track.VOID).activated();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
/** Night only supplies the alternative activation condition in normal day/night worlds. */
|
||||
public final class HeartConditions {
|
||||
private HeartConditions() { }
|
||||
public static boolean active(boolean darkness, boolean normalWorld, boolean night) { return darkness || (normalWorld && night); }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.inventory.RecipeChoice;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
public final class HeartCrafting extends ReceiptCrafting {
|
||||
public static final NamespacedKey RECIPE = new NamespacedKey("spigotignis", "heart");
|
||||
public HeartCrafting(JavaPlugin plugin, StateService states, HeartItems items, EchoGameplay dispatch) {
|
||||
super(plugin, states, items, dispatch, Track.HEART, RECIPE, result -> new ShapedRecipe(RECIPE, result));
|
||||
}
|
||||
@Override protected boolean accepts(org.bukkit.inventory.ItemStack[] matrix) { return HeartIngredients.matrix(matrix); }
|
||||
@Override protected void configure(ShapedRecipe recipe) {
|
||||
var bottle = new org.bukkit.inventory.ItemStack(org.bukkit.Material.OMINOUS_BOTTLE);
|
||||
var omen = (org.bukkit.inventory.meta.OminousBottleMeta) bottle.getItemMeta();
|
||||
omen.setAmplifier(4);
|
||||
bottle.setItemMeta(omen);
|
||||
var potion = new org.bukkit.inventory.ItemStack(org.bukkit.Material.LINGERING_POTION);
|
||||
var meta = (org.bukkit.inventory.meta.PotionMeta) potion.getItemMeta();
|
||||
meta.setBasePotionType(org.bukkit.potion.PotionType.STRONG_HEALING);
|
||||
potion.setItemMeta(meta);
|
||||
configureRecipe(recipe, new RecipeChoice.ExactChoice(bottle), new RecipeChoice.ExactChoice(potion));
|
||||
}
|
||||
static void configureRecipe(ShapedRecipe recipe, RecipeChoice bottle, RecipeChoice healing) {
|
||||
recipe.shape("PBP", "BCB", "PBP");
|
||||
recipe.setIngredient('B', bottle);
|
||||
recipe.setIngredient('P', healing);
|
||||
recipe.setIngredient('C', org.bukkit.Material.CREAKING_HEART);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
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.EntityPotionEffectEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
public final class HeartEffects implements Listener, Runnable {
|
||||
private final JavaPlugin plugin;
|
||||
private final HeartRegeneration regeneration;
|
||||
private final Predicate<Player> eligible;
|
||||
private final Function<Player, HeartRegeneration.Channel> channels;
|
||||
|
||||
public HeartEffects(JavaPlugin plugin, StateService states, HeartItems items) {
|
||||
this(plugin, new HeartRegeneration(() -> Integer.toUnsignedLong(plugin.getServer().getCurrentTick())), player -> {
|
||||
var world = player.getWorld();
|
||||
var tokens = Arrays.stream(player.getInventory().getContents()).flatMap(item -> items.identify(item).stream()).toList();
|
||||
return !player.isDead() && HeartEligibility.eligible(states.snapshot(), player.getUniqueId(), tokens,
|
||||
HeartEnvironment.dark(player.getLocation()),
|
||||
world.isNatural() && world.hasSkyLight(), !world.isDayTime());
|
||||
}, HeartEffects::channel);
|
||||
}
|
||||
HeartEffects(JavaPlugin plugin, HeartRegeneration regeneration, Predicate<Player> eligible,
|
||||
Function<Player, HeartRegeneration.Channel> channels) {
|
||||
this.plugin = plugin; this.regeneration = regeneration; this.eligible = eligible; this.channels = channels;
|
||||
}
|
||||
@Override public void run() {
|
||||
for (var player : plugin.getServer().getOnlinePlayers()) {
|
||||
regeneration.tick(player.getUniqueId(), eligible.test(player), channels.apply(player));
|
||||
}
|
||||
}
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onEffect(EntityPotionEffectEvent event) {
|
||||
if (event.getEntity() instanceof Player player && event.getModifiedType().equals(PotionEffectType.REGENERATION)) {
|
||||
regeneration.changed(player.getUniqueId(), event.getAction() != EntityPotionEffectEvent.Action.CHANGED || event.isOverride(),
|
||||
event.getNewEffect() != null);
|
||||
}
|
||||
}
|
||||
@EventHandler public void onQuit(PlayerQuitEvent event) { withdraw(event.getPlayer()); }
|
||||
private void withdraw(Player player) {
|
||||
regeneration.tick(player.getUniqueId(), false, channels.apply(player));
|
||||
regeneration.forget(player.getUniqueId());
|
||||
}
|
||||
public void close() { plugin.getServer().getOnlinePlayers().forEach(this::withdraw); }
|
||||
|
||||
private static HeartRegeneration.Channel channel(Player player) {
|
||||
return new HeartRegeneration.Channel() {
|
||||
@Override public HeartRegeneration.Effect current() {
|
||||
var current = player.getPotionEffect(PotionEffectType.REGENERATION);
|
||||
return current == null ? null : new HeartRegeneration.Effect(current.getAmplifier(), current.getDuration(),
|
||||
current.isAmbient(), current.hasParticles(), current.hasIcon());
|
||||
}
|
||||
@Override public void apply(HeartRegeneration.Effect effect) {
|
||||
player.addPotionEffect(new PotionEffect(PotionEffectType.REGENERATION, effect.ticks(), effect.amplifier(),
|
||||
effect.ambient(), effect.particles(), effect.icon()));
|
||||
}
|
||||
@Override public void remove() { player.removePotionEffect(PotionEffectType.REGENERATION); }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class HeartEligibility {
|
||||
private static final TradableRewards REWARDS = new TradableRewards(Track.HEART);
|
||||
private HeartEligibility() { }
|
||||
public static boolean eligible(IgnisState state, UUID holder, Collection<ItemToken> items,
|
||||
boolean dark, boolean normalWorld, boolean night) {
|
||||
return HeartConditions.active(dark, normalWorld, night)
|
||||
&& items.stream().anyMatch(token -> REWARDS.valid(state, holder, token));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import org.bukkit.Location;
|
||||
|
||||
/** Read-only light observation; never loads a chunk to establish eligibility. */
|
||||
public final class HeartEnvironment {
|
||||
private HeartEnvironment() { }
|
||||
public static boolean dark(Location location) {
|
||||
if (location == null || location.getWorld() == null) return false;
|
||||
var world = location.getWorld();
|
||||
return world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)
|
||||
&& world.getBlockAt(location.getBlockX(), location.getBlockY(), location.getBlockZ()).getLightLevel() == 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
public final class HeartIngredients {
|
||||
private HeartIngredients() { }
|
||||
public static boolean matrix(ItemStack[] items) {
|
||||
if (items == null || items.length != 9 || items[4] == null || items[4].getType() != org.bukkit.Material.CREAKING_HEART) return false;
|
||||
for (int slot : new int[]{0, 2, 6, 8}) if (!healing(items[slot])) return false;
|
||||
for (int slot : new int[]{1, 3, 5, 7}) if (!bottle(items[slot])) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static boolean bottle(ItemStack item) {
|
||||
return item != null && item.getType() == org.bukkit.Material.OMINOUS_BOTTLE
|
||||
&& item.getItemMeta() instanceof org.bukkit.inventory.meta.OminousBottleMeta meta
|
||||
&& meta.hasAmplifier() && meta.getAmplifier() == 4;
|
||||
}
|
||||
public static boolean healing(ItemStack item) {
|
||||
return item != null && item.getType() == org.bukkit.Material.LINGERING_POTION
|
||||
&& item.getItemMeta() instanceof org.bukkit.inventory.meta.PotionMeta meta
|
||||
&& meta.getBasePotionType() == org.bukkit.potion.PotionType.STRONG_HEALING && !meta.hasCustomEffects();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
public final class HeartItems extends ReceiptItems {
|
||||
public HeartItems() { this(() -> new ItemStack(Material.CREAKING_HEART)); }
|
||||
HeartItems(Supplier<ItemStack> factory) {
|
||||
super(Material.CREAKING_HEART, "heart_identity", "The Heart of Shadows", factory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.BlockPlaceEvent;
|
||||
|
||||
/** Issued Hearts are inventory rewards, not placeable vanilla blocks. */
|
||||
public final class HeartPlacement implements Listener {
|
||||
private final HeartItems items;
|
||||
public HeartPlacement(HeartItems items) { this.items = items; }
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onPlace(BlockPlaceEvent event) {
|
||||
if (items.identify(event.getItemInHand()).isPresent()) event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Predicate;
|
||||
import org.bukkit.Location;
|
||||
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.EntityDamageEvent;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
/** Main-thread observations; persistence is delegated to the serialized state worker. */
|
||||
public final class HeartProgressListener implements Listener, Runnable {
|
||||
private final JavaPlugin plugin;
|
||||
private final DarknessProgress progress;
|
||||
private final Predicate<Location> dark;
|
||||
private final Consumer<Throwable> errors;
|
||||
public HeartProgressListener(JavaPlugin plugin, DarknessProgress progress, Consumer<Throwable> errors) {
|
||||
this(plugin, progress, HeartEnvironment::dark, errors);
|
||||
}
|
||||
HeartProgressListener(JavaPlugin plugin, DarknessProgress progress, Predicate<Location> dark, Consumer<Throwable> errors) {
|
||||
this.plugin = plugin; this.progress = progress; this.dark = dark; this.errors = errors;
|
||||
}
|
||||
@Override public void run() {
|
||||
for (var player : plugin.getServer().getOnlinePlayers()) {
|
||||
progress.observe(player.getUniqueId(), !player.isDead() && dark.test(player.getLocation()));
|
||||
}
|
||||
}
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onDamage(EntityDamageEvent event) {
|
||||
if (!event.isCancelled() && event.getFinalDamage() > 0 && event.getEntity() instanceof Player player) {
|
||||
interruptAndSave(player.getUniqueId());
|
||||
}
|
||||
}
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onMove(PlayerMoveEvent event) {
|
||||
if (!event.isCancelled() && event.getTo() != null && !dark.test(event.getTo())) {
|
||||
progress.interrupt(event.getPlayer().getUniqueId());
|
||||
}
|
||||
}
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onTeleport(PlayerTeleportEvent event) { onMove(event); }
|
||||
@EventHandler public void onQuit(PlayerQuitEvent event) { interruptAndSave(event.getPlayer().getUniqueId()); }
|
||||
private void interruptAndSave(UUID player) {
|
||||
progress.interrupt(player);
|
||||
progress.checkpoint(player).whenComplete((ignored, error) -> { if (error != null) errors.accept(error); });
|
||||
}
|
||||
public void checkpoint() {
|
||||
progress.checkpointAll().whenComplete((ignored, error) -> { if (error != null) errors.accept(error); });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
/** Durable restart normalization for unfinished online-only darkness streaks. */
|
||||
public final class HeartProgressState {
|
||||
private HeartProgressState() { }
|
||||
public static java.util.concurrent.CompletableFuture<StateService> prepare(StateService service) {
|
||||
return service.update(HeartProgressState::restart).handle((state, error) -> {
|
||||
if (error != null) {
|
||||
service.close();
|
||||
throw new java.util.concurrent.CompletionException(error);
|
||||
}
|
||||
return service;
|
||||
});
|
||||
}
|
||||
public static IgnisState restart(IgnisState state) {
|
||||
var players = new java.util.HashMap<>(state.players());
|
||||
players.replaceAll((id, player) -> {
|
||||
var heart = player.track(Track.HEART);
|
||||
if (heart.unlocked() || heart.progress() == 0) return player;
|
||||
var tracks = new java.util.HashMap<>(player.tracks());
|
||||
tracks.put(Track.HEART, new TrackState(0, false, heart.activated(), heart.generation()));
|
||||
return new PlayerState(tracks, player.pendingRewards(), player.issuedRewards());
|
||||
});
|
||||
return new IgnisState(state.limits(), players);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/** Regeneration II overlay with restoration of an existing weaker effect. */
|
||||
public final class HeartRegeneration {
|
||||
public record Effect(int amplifier, int ticks, boolean ambient, boolean particles, boolean icon) { }
|
||||
public interface Channel {
|
||||
Effect current();
|
||||
void apply(Effect effect);
|
||||
void remove();
|
||||
}
|
||||
private record Backup(Effect effect, long tick) { }
|
||||
private final LongSupplier ticks;
|
||||
private final EffectOwnership ownership = new EffectOwnership();
|
||||
private final java.util.Set<UUID> changing = new java.util.HashSet<>();
|
||||
private final java.util.Map<UUID, Backup> backups = new java.util.HashMap<>();
|
||||
public HeartRegeneration(LongSupplier ticks) { this.ticks = ticks; }
|
||||
public void changed(UUID player, boolean actual, boolean remains) {
|
||||
if (actual && !changing.contains(player)) backups.remove(player);
|
||||
ownership.changed(player, actual, remains);
|
||||
}
|
||||
public void tick(UUID player, boolean eligible, Channel channel) {
|
||||
var current = channel.current();
|
||||
if (!eligible) {
|
||||
if (!ownership.owns(player)) return;
|
||||
mutate(player, channel::remove);
|
||||
var after = channel.current();
|
||||
if (after != null) {
|
||||
if (!after.equals(current)) forget(player); // Preserve an external replacement.
|
||||
return; // Removal was cancelled or another effect is now present.
|
||||
}
|
||||
ownership.forget(player); // Disabled plugins may not receive removal events.
|
||||
Backup backup = backups.remove(player);
|
||||
if (backup != null) {
|
||||
long elapsed = (ticks.getAsLong() - backup.tick()) & 0xffff_ffffL;
|
||||
int remaining = backup.effect().ticks() == -1 ? -1 : (int) Math.max(0L, backup.effect().ticks() - elapsed);
|
||||
if (remaining != 0) {
|
||||
Effect old = backup.effect();
|
||||
channel.apply(new Effect(old.amplifier(), remaining, old.ambient(), old.particles(), old.icon()));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (current == null || current.amplifier() < 1
|
||||
|| ownership.owns(player) && current.ticks() >= 0 && current.ticks() <= 20) {
|
||||
if (!ownership.owns(player) && current != null) backups.put(player, new Backup(current, ticks.getAsLong()));
|
||||
mutate(player, () -> channel.apply(new Effect(1, 40, true, false, true)));
|
||||
if (!ownership.owns(player)) backups.remove(player);
|
||||
}
|
||||
}
|
||||
public void forget(UUID player) {
|
||||
ownership.forget(player);
|
||||
changing.remove(player);
|
||||
backups.remove(player);
|
||||
}
|
||||
|
||||
private void mutate(UUID player, Runnable change) {
|
||||
changing.add(player);
|
||||
ownership.applying(player, true);
|
||||
try { change.run(); }
|
||||
finally { changing.remove(player); ownership.applying(player, false); }
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ public final class IgnisCommands implements CommandExecutor, TabCompleter {
|
||||
private final EchoGameplay gameplay;
|
||||
private final InvisibilityProgress maskTracker;
|
||||
private final PearlProgress pearlTracker;
|
||||
private final DarknessProgress heartTracker;
|
||||
private final Map<CommandSender, Confirmation> confirmations = new HashMap<>();
|
||||
|
||||
public IgnisCommands(JavaPlugin plugin, StateService states, EchoGameplay gameplay) {
|
||||
@@ -32,11 +33,17 @@ public final class IgnisCommands implements CommandExecutor, TabCompleter {
|
||||
|
||||
public IgnisCommands(JavaPlugin plugin, StateService states, EchoGameplay gameplay,
|
||||
InvisibilityProgress maskTracker, PearlProgress pearlTracker) {
|
||||
this(plugin, states, gameplay, maskTracker, pearlTracker, null);
|
||||
}
|
||||
|
||||
public IgnisCommands(JavaPlugin plugin, StateService states, EchoGameplay gameplay,
|
||||
InvisibilityProgress maskTracker, PearlProgress pearlTracker, DarknessProgress heartTracker) {
|
||||
this.plugin = plugin;
|
||||
this.states = states;
|
||||
this.gameplay = gameplay;
|
||||
this.maskTracker = maskTracker;
|
||||
this.pearlTracker = pearlTracker;
|
||||
this.heartTracker = heartTracker;
|
||||
}
|
||||
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
@@ -62,8 +69,19 @@ public final class IgnisCommands implements CommandExecutor, TabCompleter {
|
||||
if (args.length == 2 && args[0].equalsIgnoreCase("inspect")) {
|
||||
UUID id = resolve(args[1]);
|
||||
var player = states.snapshot().players().getOrDefault(id, PlayerState.empty());
|
||||
sender.sendMessage(id + ": " + player.tracks() + "; pending=" + player.pendingRewards().size()
|
||||
+ "; issued=" + player.issuedRewards().size());
|
||||
String progress = java.util.Arrays.stream(Track.values())
|
||||
.map(track -> track + "=" + player.track(track).progress())
|
||||
.collect(java.util.stream.Collectors.joining(", "));
|
||||
sender.sendMessage(id + ": saved progress (MASK/HEART milliseconds, DAGGER pearls): " + progress
|
||||
+ "; pending=" + player.pendingRewards().size() + "; issued=" + player.issuedRewards().size());
|
||||
sender.sendMessage("Recipe unlocks: mask=" + status(player, Track.MASK)
|
||||
+ ", dagger=" + status(player, Track.DAGGER) + ", heart=" + status(player, Track.HEART));
|
||||
sender.sendMessage("Echo: eligibility=" + status(player, Track.VOID) + "; advancement-name scrambling="
|
||||
+ (player.track(Track.VOID).activated() ? "active" : "inactive"));
|
||||
sender.sendMessage("Void challenge: " + gameplay.trialSnapshot(id)
|
||||
.map(attempt -> (attempt.escaped() ? "awaiting-end-stone" : "awaiting-elytra-ascent")
|
||||
+ "; world=" + attempt.world() + "; damage-y=" + attempt.damageHeight())
|
||||
.orElse("none"));
|
||||
} else if (args.length == 1 && args[0].equalsIgnoreCase("limits")) {
|
||||
sender.sendMessage(states.snapshot().limits().toString());
|
||||
} else if (args.length == 4 && args[0].equalsIgnoreCase("limits") && args[1].equalsIgnoreCase("set")) {
|
||||
@@ -86,8 +104,9 @@ public final class IgnisCommands implements CommandExecutor, TabCompleter {
|
||||
? maskTracker.checkpoint(id) : track == Track.DAGGER && pearlTracker != null
|
||||
? pearlTracker.checkpoint(id) : CompletableFuture.completedFuture(states.snapshot());
|
||||
mutation(sender, "progress " + id + " " + track + " " + args[1] + " " + value,
|
||||
checkpoint.thenCompose(ignored -> states.update(state -> ProgressAdministration.progress(state, id, track, value, add))));
|
||||
} else if (args.length == 3 && args[0].equalsIgnoreCase("give") && Set.of("mask", "dagger").contains(args[2].toLowerCase(Locale.ROOT))) {
|
||||
track == Track.HEART && heartTracker != null ? heartTracker.edit(id, value, add)
|
||||
: checkpoint.thenCompose(ignored -> states.update(state -> ProgressAdministration.progress(state, id, track, value, add))));
|
||||
} else if (args.length == 3 && args[0].equalsIgnoreCase("give") && Set.of("mask", "dagger", "heart").contains(args[2].toLowerCase(Locale.ROOT))) {
|
||||
UUID id = resolve(args[1]), receipt = UUID.randomUUID();
|
||||
var rewards = new TradableRewards(Track.valueOf(args[2].toUpperCase(Locale.ROOT)));
|
||||
mutation(sender, "grant " + args[2] + " " + id, states.update(state -> rewards.issue(state, id, receipt, true)));
|
||||
@@ -115,7 +134,7 @@ public final class IgnisCommands implements CommandExecutor, TabCompleter {
|
||||
} else throw new IllegalArgumentException("Use reset <player|UUID> <track|all> [confirm].");
|
||||
} else sender.sendMessage("Ignis admin: inspect <player|UUID>; limits [set <mask-ms|pearls|darkness-ms> <value>]; "
|
||||
+ "progress <set|add> <player|UUID> <mask|dagger|heart> <value>; complete <player|UUID> void; "
|
||||
+ "give <player|UUID> <echo|mask|dagger>; reset <player|UUID> <track|all> [confirm].");
|
||||
+ "give <player|UUID> <echo|mask|dagger|heart>; reset <player|UUID> <track|all> [confirm].");
|
||||
} catch (IllegalArgumentException | ArithmeticException exception) {
|
||||
sender.sendMessage("Invalid Ignis command: " + exception.getMessage());
|
||||
}
|
||||
@@ -174,7 +193,7 @@ public final class IgnisCommands implements CommandExecutor, TabCompleter {
|
||||
if (args.length == 2) options = List.of("set", "add");
|
||||
else if (args.length == 4) options = List.of("mask", "dagger", "heart");
|
||||
else if (args.length == 5) options = List.of("0", "1");
|
||||
} else if (args.length == 3 && sub.equals("give")) options = List.of("echo", "mask", "dagger");
|
||||
} else if (args.length == 3 && sub.equals("give")) options = List.of("echo", "mask", "dagger", "heart");
|
||||
else if (args.length == 3 && sub.equals("complete")) options = List.of("void");
|
||||
else if (sub.equals("reset")) {
|
||||
if (args.length == 3) options = List.of("void", "mask", "dagger", "heart", "all");
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Keyed;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.CrafterCraftEvent;
|
||||
import org.bukkit.event.inventory.CraftItemEvent;
|
||||
import org.bukkit.event.inventory.PrepareItemCraftEvent;
|
||||
import org.bukkit.inventory.CraftingInventory;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
/** Shared preissued-receipt crafting and delivery; vanilla owns ingredient consumption. */
|
||||
public abstract class ReceiptCrafting implements Listener, Runnable {
|
||||
private final JavaPlugin plugin;
|
||||
private final StateService states;
|
||||
private final ReceiptItems items;
|
||||
private final EchoGameplay dispatch;
|
||||
private final Track track;
|
||||
private final NamespacedKey key;
|
||||
private final TradableRewards rewards;
|
||||
private final Function<ItemStack, ShapedRecipe> recipeFactory;
|
||||
private boolean registered;
|
||||
private final Set<UUID> preparing = new HashSet<>();
|
||||
private final Set<ItemToken> delivering = new HashSet<>();
|
||||
private final Set<ItemToken> offered = new HashSet<>();
|
||||
|
||||
protected ReceiptCrafting(JavaPlugin plugin, StateService states, ReceiptItems items, EchoGameplay dispatch,
|
||||
Track track, NamespacedKey key, Function<ItemStack, ShapedRecipe> recipeFactory) {
|
||||
this.plugin = plugin; this.states = states; this.items = items; this.dispatch = dispatch;
|
||||
this.track = track; this.key = key; this.recipeFactory = recipeFactory;
|
||||
rewards = new TradableRewards(track);
|
||||
}
|
||||
protected abstract void configure(ShapedRecipe recipe);
|
||||
protected boolean accepts(ItemStack[] matrix) { return true; }
|
||||
|
||||
@Override public void run() {
|
||||
if (!registered) {
|
||||
var recipe = recipeFactory.apply(items.prototype());
|
||||
configure(recipe);
|
||||
registered = plugin.getServer().addRecipe(recipe);
|
||||
if (!registered) return;
|
||||
}
|
||||
for (Player player : plugin.getServer().getOnlinePlayers()) {
|
||||
var state = states.snapshot().players().getOrDefault(player.getUniqueId(), PlayerState.empty());
|
||||
if (!state.track(track).unlocked()) {
|
||||
if (player.hasDiscoveredRecipe(key)) player.undiscoverRecipe(key);
|
||||
continue;
|
||||
}
|
||||
ensureReceipt(player);
|
||||
if (!player.hasDiscoveredRecipe(key)) player.discoverRecipe(key);
|
||||
for (var entry : state.pendingRewards().entrySet()) {
|
||||
if (entry.getValue() != track) continue;
|
||||
var token = new ItemToken(player.getUniqueId(), entry.getKey(), state.track(track).generation());
|
||||
if (delivering.contains(token)) continue;
|
||||
if (!offered.contains(token) && !items.offer(player, token)) continue;
|
||||
offered.add(token);
|
||||
delivering.add(token);
|
||||
states.update(current -> rewards.acknowledge(current, token)).whenComplete((ignored, error) -> dispatch.onServer(() -> {
|
||||
delivering.remove(token);
|
||||
if (error == null) offered.remove(token);
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
private void ensureReceipt(Player player) {
|
||||
var owner = player.getUniqueId();
|
||||
var snapshot = states.snapshot();
|
||||
var state = snapshot.players().getOrDefault(owner, PlayerState.empty()).track(track);
|
||||
if (!state.unlocked() || rewards.craftingToken(snapshot, owner).isPresent() || !preparing.add(owner)) return;
|
||||
var receipt = UUID.randomUUID();
|
||||
long generation = state.generation();
|
||||
states.update(current -> {
|
||||
var now = current.players().getOrDefault(owner, PlayerState.empty()).track(track);
|
||||
return now.unlocked() && now.generation() == generation ? rewards.issue(current, owner, receipt, false) : current;
|
||||
}).whenComplete((ignored, error) -> dispatch.onServer(() -> {
|
||||
preparing.remove(owner);
|
||||
var online = plugin.getServer().getPlayer(owner);
|
||||
if (error != null || online == null) return;
|
||||
var view = online.getOpenInventory();
|
||||
if (view.getTopInventory() instanceof CraftingInventory inventory && matches(inventory.getRecipe())) {
|
||||
inventory.setResult(items.prototype());
|
||||
plugin.getServer().getPluginManager().callEvent(new PrepareItemCraftEvent(inventory, view, false));
|
||||
online.updateInventory();
|
||||
}
|
||||
}));
|
||||
}
|
||||
public void close() { if (registered) plugin.getServer().removeRecipe(key); }
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onCraft(CraftItemEvent event) {
|
||||
if (!matches(event.getRecipe())) return;
|
||||
var identity = items.identify(event.getInventory().getResult());
|
||||
if (!accepts(event.getInventory().getMatrix()) || !(event.getWhoClicked() instanceof Player player) || identity.isEmpty()
|
||||
|| !identity.orElseThrow().issuer().equals(player.getUniqueId())
|
||||
|| !rewards.valid(states.snapshot(), player.getUniqueId(), identity.orElseThrow())) {
|
||||
event.setCancelled(true);
|
||||
event.getInventory().setResult(null);
|
||||
}
|
||||
}
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onCrafter(CrafterCraftEvent event) { if (matches(event.getRecipe())) event.setCancelled(true); }
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void onPrepare(PrepareItemCraftEvent event) {
|
||||
if (!matches(event.getInventory().getRecipe()) || event.getInventory().getResult() == null) return;
|
||||
if (!accepts(event.getInventory().getMatrix()) || !(event.getView().getPlayer() instanceof Player player)) {
|
||||
event.getInventory().setResult(null);
|
||||
return;
|
||||
}
|
||||
var token = rewards.craftingToken(states.snapshot(), player.getUniqueId());
|
||||
event.getInventory().setResult(token.map(items::create).orElse(null));
|
||||
if (token.isEmpty()) ensureReceipt(player);
|
||||
}
|
||||
private boolean matches(Recipe recipe) { return recipe instanceof Keyed keyed && keyed.getKey().equals(key); }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
/** Plain-item receipt encoding and delivery; appearance and track identity are fixed by adapters. */
|
||||
public class ReceiptItems {
|
||||
private final Material material;
|
||||
private final NamespacedKey identity;
|
||||
private final String title;
|
||||
private final Supplier<ItemStack> factory;
|
||||
protected ReceiptItems(Material material, String key, String title, Supplier<ItemStack> factory) {
|
||||
this.material = material;
|
||||
this.identity = new NamespacedKey("spigotignis", key);
|
||||
this.title = title;
|
||||
this.factory = factory;
|
||||
}
|
||||
public ItemStack prototype() {
|
||||
var item = factory.get();
|
||||
var meta = item.getItemMeta();
|
||||
meta.displayName(Component.text(title, NamedTextColor.DARK_PURPLE));
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
public ItemStack create(ItemToken token) {
|
||||
var item = prototype();
|
||||
var meta = item.getItemMeta();
|
||||
meta.getPersistentDataContainer().set(identity, PersistentDataType.STRING,
|
||||
EchoIdentity.encode(new EchoToken(token.issuer(), token.receipt(), token.generation())));
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
public boolean offer(Player player, ItemToken token) {
|
||||
for (var item : player.getInventory().getContents()) {
|
||||
if (identify(item).filter(token::equals).isPresent()) return true;
|
||||
}
|
||||
if (identify(player.getItemOnCursor()).filter(token::equals).isPresent()) return true;
|
||||
if (player.getInventory().firstEmpty() < 0) return false;
|
||||
return player.getInventory().addItem(create(token)).isEmpty();
|
||||
}
|
||||
public Optional<ItemToken> identify(ItemStack item) {
|
||||
if (item == null || item.getType() != material || !item.hasItemMeta()) return Optional.empty();
|
||||
return EchoIdentity.decode(item.getItemMeta().getPersistentDataContainer().get(identity, PersistentDataType.STRING))
|
||||
.map(token -> new ItemToken(token.owner(), token.receipt(), token.generation()));
|
||||
}
|
||||
}
|
||||
@@ -6,20 +6,26 @@ import org.bukkit.plugin.java.JavaPlugin;
|
||||
/** Registers gameplay only after durable state initialization succeeds. */
|
||||
public final class SpigotIgnisPlugin extends JavaPlugin {
|
||||
private CompletableFuture<StateService> initialization;
|
||||
private AnnouncementPacketListener announcementPackets;
|
||||
private MaskProgressListener maskProgress;
|
||||
private MaskCrafting maskCrafting;
|
||||
private MaskEffects maskEffects;
|
||||
private MaskEquipmentListener maskEquipment;
|
||||
private PearlProgress pearlProgress;
|
||||
private DaggerCrafting daggerCrafting;
|
||||
private DarknessProgress heartProgress;
|
||||
private HeartCrafting heartCrafting;
|
||||
private HeartEffects heartEffects;
|
||||
private final java.util.concurrent.atomic.AtomicBoolean heartSaveWarning = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
private final java.util.concurrent.atomic.AtomicBoolean pearlSaveWarning = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
initialization = StateService.open(new FileStateRepository(getDataFolder().toPath().resolve("state.bin")));
|
||||
initialization = StateService.open(new FileStateRepository(getDataFolder().toPath().resolve("state.bin")))
|
||||
.thenCompose(HeartProgressState::prepare);
|
||||
initialization.whenComplete((service, error) -> {
|
||||
if (error != null) {
|
||||
getLogger().severe("Ignis state unavailable; original data retained. Restore a verified backup before retrying.");
|
||||
getLogger().severe("Ignis state initialization failed; original data retained. Check storage and validated recovery guidance before retrying.");
|
||||
} else {
|
||||
if (!isEnabled()) { service.close(); return; }
|
||||
try {
|
||||
@@ -28,6 +34,8 @@ public final class SpigotIgnisPlugin extends JavaPlugin {
|
||||
var gameplay = new EchoGameplay(this, service, new EchoItems());
|
||||
getServer().getPluginManager().registerEvents(gameplay, this);
|
||||
getServer().getPluginManager().registerEvents(new AnnouncementListener(gameplay::activated), this);
|
||||
announcementPackets = new AnnouncementPacketListener(this, gameplay::activated, getLogger()::warning);
|
||||
com.comphenix.protocol.ProtocolLibrary.getProtocolManager().addPacketListener(announcementPackets);
|
||||
getServer().getScheduler().runTaskTimer(this, gameplay::retryDeliveries, 1L, 100L);
|
||||
var maskTracker = new InvisibilityProgress(service, System::nanoTime);
|
||||
maskProgress = new MaskProgressListener(this, maskTracker);
|
||||
@@ -58,13 +66,26 @@ public final class SpigotIgnisPlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(new DaggerUse(service::snapshot, daggerItems,
|
||||
new DaggerCooldown(System::nanoTime), DaggerSafety::safe), this);
|
||||
getServer().getScheduler().runTaskTimer(this, daggerCrafting, 1L, 20L);
|
||||
var commands = new IgnisCommands(this, service, gameplay, maskTracker, pearlProgress);
|
||||
heartProgress = new DarknessProgress(service, System::nanoTime);
|
||||
var heartListener = new HeartProgressListener(this, heartProgress, this::reportHeartSave);
|
||||
var heartItems = new HeartItems();
|
||||
heartCrafting = new HeartCrafting(this, service, heartItems, gameplay);
|
||||
heartEffects = new HeartEffects(this, service, heartItems);
|
||||
getServer().getPluginManager().registerEvents(heartListener, this);
|
||||
getServer().getPluginManager().registerEvents(heartCrafting, this);
|
||||
getServer().getPluginManager().registerEvents(heartEffects, this);
|
||||
getServer().getPluginManager().registerEvents(new HeartPlacement(heartItems), this);
|
||||
getServer().getScheduler().runTaskTimer(this, heartListener, 1L, 1L);
|
||||
getServer().getScheduler().runTaskTimer(this, heartListener::checkpoint, 100L, 100L);
|
||||
getServer().getScheduler().runTaskTimer(this, heartCrafting, 1L, 20L);
|
||||
getServer().getScheduler().runTaskTimer(this, heartEffects, 1L, 1L);
|
||||
var commands = new IgnisCommands(this, service, gameplay, maskTracker, pearlProgress, heartProgress);
|
||||
for (String name : java.util.List.of("ignis", "ignisadmin")) {
|
||||
var command = java.util.Objects.requireNonNull(getCommand(name), "Missing command " + name);
|
||||
command.setExecutor(commands);
|
||||
command.setTabCompleter(commands);
|
||||
}
|
||||
getLogger().info("Ignis Echo, Mask and Dagger enabled. Heart remains in development.");
|
||||
getLogger().info("Ignis Echo, Mask, Dagger and Heart enabled.");
|
||||
});
|
||||
} catch (org.bukkit.plugin.IllegalPluginAccessException ignored) {
|
||||
service.close();
|
||||
@@ -76,20 +97,32 @@ public final class SpigotIgnisPlugin extends JavaPlugin {
|
||||
@Override
|
||||
public void onDisable() {
|
||||
try {
|
||||
if (announcementPackets != null) com.comphenix.protocol.ProtocolLibrary.getProtocolManager().removePacketListener(announcementPackets);
|
||||
if (heartEffects != null) heartEffects.close();
|
||||
if (heartCrafting != null) heartCrafting.close();
|
||||
if (maskEffects != null) maskEffects.close();
|
||||
if (maskEquipment != null) com.comphenix.protocol.ProtocolLibrary.getProtocolManager().removePacketListener(maskEquipment);
|
||||
if (maskCrafting != null) maskCrafting.close();
|
||||
if (daggerCrafting != null) daggerCrafting.close();
|
||||
if (maskProgress != null) maskProgress.stop();
|
||||
} finally {
|
||||
var drain = pearlProgress == null ? CompletableFuture.<Void>completedFuture(null) : pearlProgress.checkpointAll();
|
||||
drain.whenComplete((saved, failure) -> {
|
||||
reportPearlSave(failure);
|
||||
var pearlDrain = (pearlProgress == null ? CompletableFuture.<Void>completedFuture(null) : pearlProgress.checkpointAll())
|
||||
.whenComplete((saved, failure) -> reportPearlSave(failure));
|
||||
var heartDrain = (heartProgress == null ? CompletableFuture.<Void>completedFuture(null) : heartProgress.stopAll())
|
||||
.whenComplete((saved, failure) -> reportHeartSave(failure));
|
||||
CompletableFuture.allOf(pearlDrain, heartDrain).whenComplete((saved, failure) -> {
|
||||
if (initialization != null) initialization.thenAccept(StateService::close);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void reportHeartSave(Throwable failure) {
|
||||
if (failure == null) heartSaveWarning.set(false);
|
||||
else if (heartSaveWarning.compareAndSet(false, true)) {
|
||||
getLogger().warning("Heart progress save failed; desired state remains in memory for retry while enabled.");
|
||||
}
|
||||
}
|
||||
|
||||
private void reportPearlSave(Throwable failure) {
|
||||
if (failure == null) pearlSaveWarning.set(false);
|
||||
else if (pearlSaveWarning.compareAndSet(false, true)) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import java.util.UUID;
|
||||
|
||||
/** Server-thread transient attempts. Never persisted across disconnect/restart. */
|
||||
public final class VoidTrial {
|
||||
public record Snapshot(UUID world, double damageHeight, boolean escaped) { }
|
||||
private record Attempt(UUID world, double damageHeight, boolean escaped) { }
|
||||
private final Map<UUID, Attempt> attempts = new HashMap<>();
|
||||
|
||||
@@ -30,5 +31,12 @@ public final class VoidTrial {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Server-thread read only: never advances, cancels or persists an attempt. */
|
||||
public java.util.Optional<Snapshot> snapshot(UUID player) {
|
||||
Attempt attempt = attempts.get(player);
|
||||
return attempt == null ? java.util.Optional.empty()
|
||||
: java.util.Optional.of(new Snapshot(attempt.world(), attempt.damageHeight(), attempt.escaped()));
|
||||
}
|
||||
|
||||
public void cancel(UUID player) { attempts.remove(player); }
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import com.comphenix.protocol.ProtocolLibrary;
|
||||
import com.comphenix.protocol.ProtocolManager;
|
||||
import com.comphenix.protocol.events.PacketListener;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.craftbukkit.CraftServer;
|
||||
import org.bukkit.craftbukkit.scheduler.CraftScheduler;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class AnnouncementPluginLifecycleTest {
|
||||
@TempDir Path directory;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test
|
||||
@SuppressWarnings({"try", "unchecked"})
|
||||
void actualEntrypointRegistersAndRemovesTheAnnouncementWireBackstop() throws Exception {
|
||||
var server = mock(CraftServer.class);
|
||||
try (var protocol = mockStatic(ProtocolLibrary.class);
|
||||
var platform = mockStatic(Bukkit.class, call -> switch (call.getMethod().getName()) {
|
||||
case "getServer" -> server;
|
||||
case "isPrimaryThread" -> true;
|
||||
case "getVersion" -> "Purpur 2618 (MC: 26.2)";
|
||||
case "getMinecraftVersion" -> "26.2";
|
||||
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT";
|
||||
default -> call.callRealMethod();
|
||||
})) {
|
||||
var packets = new ArrayList<PacketListener>();
|
||||
var manager = mock(ProtocolManager.class);
|
||||
protocol.when(ProtocolLibrary::getProtocolManager).thenReturn(manager);
|
||||
doAnswer(call -> { packets.add(call.getArgument(0)); return null; }).when(manager).addPacketListener(any());
|
||||
doAnswer(call -> { packets.remove(call.getArgument(0)); return null; }).when(manager).removePacketListener(any());
|
||||
var plugin = mock(SpigotIgnisPlugin.class, CALLS_REAL_METHODS);
|
||||
doReturn(true).when(plugin).isEnabled();
|
||||
doReturn(server).when(plugin).getServer();
|
||||
doReturn("SpigotIgnis").when(plugin).getName();
|
||||
doReturn(directory.toFile()).when(plugin).getDataFolder();
|
||||
doReturn(java.util.logging.Logger.getAnonymousLogger()).when(plugin).getLogger();
|
||||
doReturn(mock(org.bukkit.command.PluginCommand.class)).when(plugin).getCommand(anyString());
|
||||
for (var name : java.util.List.of("heartSaveWarning", "pearlSaveWarning")) {
|
||||
var field = SpigotIgnisPlugin.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(plugin, new java.util.concurrent.atomic.AtomicBoolean());
|
||||
}
|
||||
var scheduler = mock(CraftScheduler.class);
|
||||
when(server.getScheduler()).thenReturn(scheduler);
|
||||
var callbacks = new LinkedBlockingQueue<Runnable>();
|
||||
when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(call -> { callbacks.add(call.getArgument(1)); return null; });
|
||||
var events = new ArrayList<org.bukkit.event.Listener>();
|
||||
var plugins = mock(org.bukkit.plugin.PluginManager.class);
|
||||
when(server.getPluginManager()).thenReturn(plugins);
|
||||
doAnswer(call -> { events.add(call.getArgument(0)); return null; }).when(plugins).registerEvents(any(), eq(plugin));
|
||||
plugin.onEnable();
|
||||
var initialization = SpigotIgnisPlugin.class.getDeclaredField("initialization");
|
||||
initialization.setAccessible(true);
|
||||
var service = ((CompletableFuture<StateService>) initialization.get(plugin)).get(5, TimeUnit.SECONDS);
|
||||
try {
|
||||
var callback = callbacks.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(callback);
|
||||
callback.run();
|
||||
assertTrue(events.stream().anyMatch(AnnouncementListener.class::isInstance));
|
||||
assertTrue(events.stream().anyMatch(DaggerCrafting.class::isInstance), "actual startup must register paid Dagger crafting");
|
||||
assertTrue(events.stream().anyMatch(DaggerUse.class::isInstance), "actual startup must register Dagger right-click handling");
|
||||
assertTrue(packets.stream().anyMatch(AnnouncementPacketListener.class::isInstance), "native delivery backstop must be registered by the real plugin");
|
||||
verify(server, never()).createPlayerProfile(anyString());
|
||||
} finally {
|
||||
plugin.onDisable();
|
||||
service.close();
|
||||
var worker = StateService.class.getDeclaredField("worker");
|
||||
worker.setAccessible(true);
|
||||
assertTrue(((java.util.concurrent.ExecutorService) worker.get(service)).awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
assertTrue(packets.stream().noneMatch(AnnouncementPacketListener.class::isInstance), "disable must remove the wire listener");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.nio.file.Path;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
class NativeDaggerBlinkTest {
|
||||
@TempDir Path directory;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {true, false})
|
||||
void craftedDaggerBlinksThroughNativeInteractionAndMovementOnGroundOrMidair(boolean grounded) throws Exception {
|
||||
NativeDaggerFixture.onTick(() -> {
|
||||
try (var f = new NativeDaggerFixture(directory)) {
|
||||
f.floor = grounded;
|
||||
double y = grounded ? 64 : 80;
|
||||
f.position(8, y, 8, 0, 0);
|
||||
var dagger = f.craft().clone();
|
||||
f.usePacket();
|
||||
assertEquals(new Vec3(8, y, 11), f.nativePlayer.position(), "native crafted-item activation must move the player");
|
||||
assertEquals(1, f.packets.size(), "actual native teleport dispatch must emit a position packet");
|
||||
assertEquals(f.nativePlayer.position(), f.packets.getFirst().change().position());
|
||||
assertEquals(org.bukkit.event.player.PlayerTeleportEvent.TeleportCause.PLUGIN, f.teleports.getFirst().getCause());
|
||||
assertEquals(dagger, f.inventory.getItem(0), "the actual crafted item is reusable");
|
||||
assertEquals(0, f.player.getFoodLevel());
|
||||
assertEquals(.5f, f.food.exhaustionLevel);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.CsvSource({"0,0", "45,-30", "-90,30", "0,-90", "0,90"})
|
||||
void airbornePacketActivationFollowsThreeDimensionalAimWithoutFlightOrFallImmunityOrHungerCost(float yaw, float pitch) throws Exception {
|
||||
NativeDaggerFixture.onTick(() -> {
|
||||
try (var f = new NativeDaggerFixture(directory)) {
|
||||
f.floor = false;
|
||||
f.position(8, 80, 8, yaw, pitch);
|
||||
f.craft();
|
||||
f.nativePlayer.fallDistance = 7;
|
||||
var expected = f.player.getLocation().toVector().add(f.player.getEyeLocation().getDirection().multiply(3));
|
||||
f.usePacket();
|
||||
assertEquals(expected.getX(), f.nativePlayer.position().x, 1e-10);
|
||||
assertEquals(expected.getY(), f.nativePlayer.position().y, 1e-10);
|
||||
assertEquals(expected.getZ(), f.nativePlayer.position().z, 1e-10);
|
||||
assertEquals(yaw, f.nativePlayer.getYRot());
|
||||
assertEquals(pitch, f.nativePlayer.getXRot());
|
||||
assertEquals(7, f.nativePlayer.fallDistance);
|
||||
assertEquals(0, f.food.getFoodLevel());
|
||||
assertEquals(.5f, f.food.exhaustionLevel);
|
||||
verify(f.player, never()).setAllowFlight(anyBoolean());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {true, false})
|
||||
void nativePlayerTargetBlinkWorksOnGroundOrInAirAndRetainsLook(boolean grounded) throws Exception {
|
||||
NativeDaggerFixture.onTick(() -> {
|
||||
try (var f = new NativeDaggerFixture(directory)) {
|
||||
f.floor = grounded;
|
||||
double y = grounded ? 64 : 80;
|
||||
f.position(8, y, 8, 8, -4);
|
||||
var dagger = f.craft().clone();
|
||||
f.target(8, y, 13, 0);
|
||||
f.usePacket();
|
||||
assertEquals(new Vec3(8, y, 11.5), f.nativePlayer.position());
|
||||
assertEquals(8, f.nativePlayer.getYRot());
|
||||
assertEquals(-4, f.nativePlayer.getXRot());
|
||||
assertEquals(dagger, f.inventory.getItem(0));
|
||||
assertEquals(0, f.food.getFoodLevel());
|
||||
assertEquals(1, f.teleports.size());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void nativeVaultInteractionsCannotConsumeIssuedOrRevokedDaggers(boolean revoked) throws Exception {
|
||||
NativeDaggerFixture.onTick(() -> {
|
||||
try (var f = new NativeDaggerFixture(directory)) {
|
||||
var dagger = f.craft().clone();
|
||||
if (revoked) f.states.update(state -> ProgressAdministration.reset(state, f.owner, java.util.Set.of(Track.DAGGER))).get();
|
||||
f.blocks.put(new BlockPos(8, 64, 9), Blocks.VAULT.defaultBlockState());
|
||||
var event = org.bukkit.craftbukkit.event.CraftEventFactory.callPlayerInteractEvent(f.nativePlayer,
|
||||
org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK, new BlockPos(8, 64, 9), net.minecraft.core.Direction.NORTH,
|
||||
f.nativeInventory.getItem(0), net.minecraft.world.InteractionHand.MAIN_HAND);
|
||||
assertEquals(org.bukkit.Material.VAULT, event.getClickedBlock().getType());
|
||||
assertEquals(org.bukkit.event.Event.Result.DENY, event.useInteractedBlock());
|
||||
assertEquals(org.bukkit.event.Event.Result.DENY, event.useItemInHand());
|
||||
assertEquals(dagger, f.inventory.getItem(0));
|
||||
assertTrue(f.packets.isEmpty());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"thin-wall", "hazard", "hazard-surface", "liquid", "unloaded", "border", "ceiling", "bottom", "oversized"})
|
||||
void nativeSafetyRefusalsLeaveThePaidItemAndPositionUntouched(String reason) throws Exception {
|
||||
NativeDaggerFixture.onTick(() -> {
|
||||
try (var f = new NativeDaggerFixture(directory)) {
|
||||
var dagger = f.craft().clone();
|
||||
var border = f.world.getWorldBorder();
|
||||
switch (reason) {
|
||||
case "thin-wall" -> f.blocks.put(new BlockPos(8, 64, 9), Blocks.OAK_TRAPDOOR.defaultBlockState()
|
||||
.setValue(net.minecraft.world.level.block.TrapDoorBlock.OPEN, true));
|
||||
case "hazard" -> f.blocks.put(new BlockPos(8, 64, 11), Blocks.FIRE.defaultBlockState());
|
||||
case "hazard-surface" -> f.blocks.put(new BlockPos(8, 63, 11), Blocks.MAGMA_BLOCK.defaultBlockState());
|
||||
case "liquid" -> f.blocks.put(new BlockPos(8, 64, 11), Blocks.WATER.defaultBlockState());
|
||||
case "unloaded" -> when(f.world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false);
|
||||
case "border" -> when(border.isInside(any())).thenReturn(false);
|
||||
case "ceiling" -> f.position(8, 319, 8, 0, 0);
|
||||
case "bottom" -> f.position(8, -63, 8, 0, 90);
|
||||
case "oversized" -> f.nativePlayer.setBoundingBox(new net.minecraft.world.phys.AABB(-24, 64, -24, 40, 128, 40));
|
||||
default -> fail(reason);
|
||||
}
|
||||
var origin = f.nativePlayer.position();
|
||||
clearInvocations(f.world);
|
||||
f.usePacket();
|
||||
assertEquals(origin, f.nativePlayer.position());
|
||||
assertTrue(f.packets.isEmpty());
|
||||
assertTrue(f.teleports.isEmpty(), "unsafe attempts must never reach native teleport dispatch");
|
||||
assertEquals(dagger, f.inventory.getItem(0));
|
||||
if (reason.equals("unloaded") || reason.equals("oversized")) {
|
||||
verify(f.world, never()).getBlockAt(anyInt(), anyInt(), anyInt());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"item-veto", "block-veto", "teleport-cancelled", "reset", "reset-reearned", "unknown-receipt", "locked-holder", "forged-name"})
|
||||
void nativeProtectionAndReceiptBoundariesCannotBeBypassed(String reason) throws Exception {
|
||||
NativeDaggerFixture.onTick(() -> {
|
||||
try (var f = new NativeDaggerFixture(directory)) {
|
||||
f.craft();
|
||||
switch (reason) {
|
||||
case "item-veto" -> f.beforeEvent = event -> {
|
||||
if (event instanceof org.bukkit.event.player.PlayerInteractEvent use) use.setUseItemInHand(org.bukkit.event.Event.Result.DENY);
|
||||
};
|
||||
case "block-veto" -> f.beforeEvent = event -> {
|
||||
if (event instanceof org.bukkit.event.player.PlayerInteractEvent use) use.setUseInteractedBlock(org.bukkit.event.Event.Result.DENY);
|
||||
};
|
||||
case "teleport-cancelled" -> f.cancelTeleport = true;
|
||||
case "reset", "reset-reearned" -> {
|
||||
f.states.update(state -> ProgressAdministration.reset(state, f.owner, java.util.Set.of(Track.DAGGER))).get();
|
||||
if (reason.equals("reset-reearned")) {
|
||||
f.states.update(state -> ProgressAdministration.progress(state, f.owner, Track.DAGGER, 313, false)).get();
|
||||
}
|
||||
}
|
||||
case "unknown-receipt" -> f.inventory.setItem(0, f.items.create(new ItemToken(f.owner, java.util.UUID.randomUUID(), 0)));
|
||||
case "locked-holder" -> when(f.player.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
case "forged-name" -> f.inventory.setItem(0, f.items.prototype());
|
||||
default -> fail(reason);
|
||||
}
|
||||
var item = f.inventory.getItem(0).clone();
|
||||
var origin = f.nativePlayer.position();
|
||||
if (reason.equals("block-veto")) {
|
||||
org.bukkit.craftbukkit.event.CraftEventFactory.callPlayerInteractEvent(f.nativePlayer,
|
||||
org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK, new BlockPos(8, 63, 8), net.minecraft.core.Direction.UP,
|
||||
f.nativeInventory.getItem(0), net.minecraft.world.InteractionHand.MAIN_HAND);
|
||||
} else {
|
||||
f.use(); // Actual native event factory; forged keys retain vanilla's unrelated item path.
|
||||
}
|
||||
assertEquals(origin, f.nativePlayer.position());
|
||||
assertEquals(item, f.inventory.getItem(0));
|
||||
assertTrue(f.packets.isEmpty());
|
||||
assertEquals(reason.equals("teleport-cancelled") ? 1 : 0, f.teleports.size());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"copies", "unsafe", "cancelled"})
|
||||
void sharedCooldownSurvivesCopiesFailuresAndNativeCancellationWithoutSpamExtension(String mode) throws Exception {
|
||||
NativeDaggerFixture.onTick(() -> {
|
||||
try (var f = new NativeDaggerFixture(directory)) {
|
||||
var copy = f.craft().clone();
|
||||
if (mode.equals("unsafe")) f.blocks.put(new BlockPos(8, 64, 9), Blocks.STONE.defaultBlockState());
|
||||
if (mode.equals("cancelled")) f.cancelTeleport = true;
|
||||
f.usePacket();
|
||||
int first = mode.equals("copies") ? 1 : 0;
|
||||
assertEquals(first, f.packets.size());
|
||||
f.inventory.setItem(0, copy.clone());
|
||||
f.blocks.clear();
|
||||
f.cancelTeleport = false;
|
||||
f.clock.set(1_999_999_999L);
|
||||
f.usePacket();
|
||||
assertEquals(first, f.packets.size());
|
||||
f.clock.set(2_000_000_000L);
|
||||
f.usePacket();
|
||||
assertEquals(first + 1, f.packets.size());
|
||||
assertEquals(copy, f.inventory.getItem(0));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@org.junit.jupiter.api.Test
|
||||
void blockedSelectedTargetDoesNotFallBackToAnOtherwiseSafeForwardBlink() throws Exception {
|
||||
NativeDaggerFixture.onTick(() -> {
|
||||
try (var f = new NativeDaggerFixture(directory)) {
|
||||
f.craft();
|
||||
f.target(8, 64, 13, 180);
|
||||
f.blocks.put(new BlockPos(8, 64, 14), Blocks.STONE.defaultBlockState());
|
||||
assertTrue(DaggerSafety.safe(f.player, new org.bukkit.Location(f.world, 8, 64, 11)));
|
||||
f.usePacket();
|
||||
assertEquals(new Vec3(8, 64, 8), f.nativePlayer.position());
|
||||
assertTrue(f.packets.isEmpty());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"invisible", "vanished", "dead", "spectator", "no-line-of-sight", "out-of-range", "outside-cone", "unloaded-corridor"})
|
||||
void ineligibleTargetsLeaveTheNormalForwardBlinkAvailable(String reason) throws Exception {
|
||||
NativeDaggerFixture.onTick(() -> {
|
||||
try (var f = new NativeDaggerFixture(directory)) {
|
||||
f.craft();
|
||||
double x = reason.equals("outside-cone") ? 13 : 8;
|
||||
double z = reason.equals("out-of-range") ? 17 : reason.equals("unloaded-corridor") ? 16 : 13;
|
||||
var target = f.target(x, 64, z, 0);
|
||||
switch (reason) {
|
||||
case "invisible" -> when(target.isInvisible()).thenReturn(true);
|
||||
case "vanished" -> when(f.player.canSee(target)).thenReturn(false);
|
||||
case "dead" -> when(target.isDead()).thenReturn(true);
|
||||
case "spectator" -> when(target.getGameMode()).thenReturn(org.bukkit.GameMode.SPECTATOR);
|
||||
case "no-line-of-sight" -> when(f.player.hasLineOfSight(target)).thenReturn(false);
|
||||
case "unloaded-corridor" -> when(f.world.isChunkLoaded(0, 1)).thenReturn(false);
|
||||
case "out-of-range", "outside-cone" -> { }
|
||||
default -> fail(reason);
|
||||
}
|
||||
f.usePacket();
|
||||
assertEquals(new Vec3(8, 64, 11), f.nativePlayer.position());
|
||||
if (reason.equals("out-of-range") || reason.equals("unloaded-corridor")) {
|
||||
verify(f.player, never()).hasLineOfSight(target);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void unlockedRecipientsCanUseTradedCopiesButIssuerResetStillRevokesThem(boolean resetIssuer) throws Exception {
|
||||
NativeDaggerFixture.onTick(() -> {
|
||||
try (var f = new NativeDaggerFixture(directory)) {
|
||||
var copy = f.craft().clone();
|
||||
var recipient = java.util.UUID.randomUUID();
|
||||
when(f.player.getUniqueId()).thenReturn(recipient);
|
||||
f.states.update(state -> ProgressAdministration.progress(state, recipient, Track.DAGGER, 313, false)).get();
|
||||
if (resetIssuer) {
|
||||
f.states.update(state -> ProgressAdministration.reset(state, f.owner, java.util.Set.of(Track.DAGGER))).get();
|
||||
f.states.update(state -> ProgressAdministration.progress(state, f.owner, Track.DAGGER, 313, false)).get();
|
||||
}
|
||||
f.usePacket();
|
||||
assertEquals(new Vec3(8, 64, resetIssuer ? 8 : 11), f.nativePlayer.position());
|
||||
assertEquals(copy, f.inventory.getItem(0));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@org.junit.jupiter.api.Test
|
||||
void actualOffhandPacketCanUseTheReusableCraftedDagger() throws Exception {
|
||||
NativeDaggerFixture.onTick(() -> {
|
||||
try (var f = new NativeDaggerFixture(directory)) {
|
||||
var dagger = f.craft().clone();
|
||||
f.inventory.setItem(0, null);
|
||||
f.inventory.setItem(40, dagger);
|
||||
f.usePacket(net.minecraft.world.InteractionHand.OFF_HAND);
|
||||
assertEquals(new Vec3(8, 64, 11), f.nativePlayer.position());
|
||||
assertEquals(dagger, f.inventory.getItem(40));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import ca.spottedleaf.moonrise.common.util.TickThread;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.network.protocol.game.ClientboundPlayerPositionPacket;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.server.network.ServerGamePacketListenerImpl;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.EntityEquipment;
|
||||
import net.minecraft.world.entity.EntityTypes;
|
||||
import net.minecraft.world.entity.PositionMoveRotation;
|
||||
import net.minecraft.world.entity.player.Inventory;
|
||||
import net.minecraft.world.inventory.CraftingMenu;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.portal.TeleportTransition;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.craftbukkit.CraftServer;
|
||||
import org.bukkit.craftbukkit.CraftWorld;
|
||||
import org.bukkit.craftbukkit.block.CraftBlock;
|
||||
import org.bukkit.craftbukkit.entity.CraftEntity;
|
||||
import org.bukkit.craftbukkit.entity.CraftPlayer;
|
||||
import org.bukkit.craftbukkit.event.CraftEventFactory;
|
||||
import org.bukkit.craftbukkit.inventory.CraftInventoryPlayer;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemStack;
|
||||
import org.bukkit.craftbukkit.inventory.CraftRecipe;
|
||||
import org.bukkit.craftbukkit.scheduler.CraftScheduler;
|
||||
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.inventory.CraftItemEvent;
|
||||
import org.bukkit.event.inventory.InventoryType;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.plugin.EventExecutor;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.bukkit.plugin.RegisteredListener;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
/** Native items, recipe/result slots, interaction events, block shapes and teleport/position packet path.
|
||||
* Server/session, scheduler/lookup, visibility and finite-world queries are modeled ports;
|
||||
* selected native methods run unchanged, with real temporary-file persistence. */
|
||||
final class NativeDaggerFixture implements AutoCloseable {
|
||||
final UUID owner = UUID.randomUUID();
|
||||
final CraftServer server = mock(CraftServer.class);
|
||||
final JavaPlugin plugin = mock(JavaPlugin.class);
|
||||
final CraftWorld world = mock(CraftWorld.class);
|
||||
final ServerLevel level = mock(ServerLevel.class);
|
||||
final CraftPlayer player = mock(CraftPlayer.class, call -> call.getMethod().getName().equals("teleport0")
|
||||
? call.callRealMethod() : org.mockito.Answers.RETURNS_DEFAULTS.answer(call));
|
||||
final ServerPlayer nativePlayer = mock(ServerPlayer.class, call -> java.util.Set.of("makeBoundingBox", "reapplyPosition").contains(call.getMethod().getName())
|
||||
? call.callRealMethod() : org.mockito.Answers.RETURNS_DEFAULTS.answer(call));
|
||||
final ServerGamePacketListenerImpl connection = mock(ServerGamePacketListenerImpl.class);
|
||||
final PluginManager events = mock(PluginManager.class);
|
||||
final org.mockito.MockedStatic<Bukkit> platform;
|
||||
final org.mockito.MockedStatic<io.papermc.paper.configuration.GlobalConfiguration> settings;
|
||||
final net.minecraft.world.food.FoodData food = new net.minecraft.world.food.FoodData();
|
||||
final AtomicLong clock = new AtomicLong();
|
||||
final Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
final List<org.bukkit.entity.Player> players = new ArrayList<>();
|
||||
final List<ClientboundPlayerPositionPacket> packets = new ArrayList<>();
|
||||
final List<PlayerTeleportEvent> teleports = new ArrayList<>();
|
||||
final LinkedBlockingQueue<Runnable> callbacks = new LinkedBlockingQueue<>();
|
||||
final DaggerItems items = new DaggerItems();
|
||||
final CraftInventoryPlayer inventory;
|
||||
final Inventory nativeInventory;
|
||||
final CraftingMenu menu;
|
||||
final StateService states;
|
||||
final DaggerCrafting crafting;
|
||||
ShapedRecipe recipe;
|
||||
boolean floor = true, cancelTeleport;
|
||||
java.util.function.Consumer<Event> beforeEvent = ignored -> { };
|
||||
|
||||
NativeDaggerFixture(Path directory) throws Exception {
|
||||
assertTrue(TickThread.isTickThread(), "execute world/movement work on a real native tick thread");
|
||||
platform = mockStatic(Bukkit.class, call -> switch (call.getMethod().getName()) {
|
||||
case "getServer" -> server;
|
||||
case "getPluginManager" -> events;
|
||||
case "getItemFactory" -> CraftItemFactory.instance();
|
||||
case "getUnsafe" -> CraftMagicNumbers.INSTANCE;
|
||||
case "isPrimaryThread" -> TickThread.isTickThread();
|
||||
default -> call.callRealMethod();
|
||||
});
|
||||
var configuration = new io.papermc.paper.configuration.GlobalConfiguration();
|
||||
configuration.spamLimiter = configuration.new SpamLimiter(); // Native defaults; no packet-limit bypass.
|
||||
settings = mockStatic(io.papermc.paper.configuration.GlobalConfiguration.class);
|
||||
settings.when(io.papermc.paper.configuration.GlobalConfiguration::get).thenReturn(configuration);
|
||||
when(server.getItemFactory()).thenReturn(CraftItemFactory.instance());
|
||||
when(server.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
when(server.getPlayer(owner)).thenReturn(player);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getName()).thenReturn("SpigotIgnis");
|
||||
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
|
||||
when(player.getServer()).thenReturn(server);
|
||||
when(player.getUniqueId()).thenReturn(owner);
|
||||
when(player.getHandle()).thenReturn(nativePlayer);
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
when(player.getGameMode()).thenReturn(org.bukkit.GameMode.SURVIVAL);
|
||||
when(player.getEyeHeight()).thenReturn(1.62);
|
||||
when(player.canSee(any(org.bukkit.entity.Player.class))).thenReturn(true);
|
||||
when(player.hasLineOfSight(any(org.bukkit.entity.Entity.class))).thenReturn(true);
|
||||
when(nativePlayer.getBukkitEntity()).thenReturn(player);
|
||||
when(nativePlayer.level()).thenReturn(level);
|
||||
food.setFoodLevel(0);
|
||||
food.setSaturation(0);
|
||||
food.exhaustionLevel = .5f;
|
||||
when(nativePlayer.getFoodData()).thenReturn(food);
|
||||
doCallRealMethod().when(player).getFoodLevel();
|
||||
var gameServer = mock(net.minecraft.server.MinecraftServer.class);
|
||||
var processor = new net.minecraft.network.PacketProcessor(Thread.currentThread());
|
||||
when(gameServer.packetProcessor()).thenReturn(processor);
|
||||
when(level.getServer()).thenReturn(gameServer);
|
||||
set(net.minecraft.world.level.Level.class, level, "purpurConfig", mock(org.purpurmc.purpur.PurpurWorldConfig.class));
|
||||
set(ServerPlayer.class, nativePlayer, "gameMode", new net.minecraft.server.level.ServerPlayerGameMode(nativePlayer));
|
||||
when(level.getWorld()).thenReturn(world);
|
||||
when(level.dimension()).thenReturn(net.minecraft.world.level.Level.OVERWORLD);
|
||||
when(level.getTypeKey()).thenReturn(net.minecraft.world.level.dimension.LevelStem.OVERWORLD);
|
||||
when(level.enabledFeatures()).thenReturn(net.minecraft.world.flag.FeatureFlags.VANILLA_SET);
|
||||
when(world.getHandle()).thenReturn(level);
|
||||
when(world.getMinHeight()).thenReturn(-64);
|
||||
when(world.getMaxHeight()).thenReturn(320);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
when(world.getPlayers()).thenReturn(players);
|
||||
players.add(player);
|
||||
var border = mock(org.bukkit.WorldBorder.class);
|
||||
when(world.getWorldBorder()).thenReturn(border);
|
||||
when(border.isInside(any(Location.class))).thenReturn(true);
|
||||
when(level.getBlockState(any(BlockPos.class))).thenAnswer(call -> {
|
||||
var pos = call.getArgument(0, BlockPos.class);
|
||||
return blocks.getOrDefault(pos, floor && pos.getY() == 63 ? Blocks.STONE.defaultBlockState() : Blocks.AIR.defaultBlockState());
|
||||
});
|
||||
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(call ->
|
||||
CraftBlock.at(level, new BlockPos(call.getArgument(0, Integer.class), call.getArgument(1, Integer.class), call.getArgument(2, Integer.class))));
|
||||
// Keep the pre-fix supported-exit port for regression replay, using native block shapes.
|
||||
when(level.noCollision(eq(nativePlayer), any(net.minecraft.world.phys.AABB.class))).thenAnswer(call -> {
|
||||
var body = call.getArgument(1, net.minecraft.world.phys.AABB.class);
|
||||
for (int x = (int) Math.floor(body.minX); x <= Math.floor(body.maxX); x++) {
|
||||
for (int y = (int) Math.floor(body.minY); y <= Math.floor(body.maxY); y++) {
|
||||
for (int z = (int) Math.floor(body.minZ); z <= Math.floor(body.maxZ); z++) {
|
||||
var pos = new BlockPos(x, y, z);
|
||||
for (var box : level.getBlockState(pos).getCollisionShape(level, pos).toAabbs()) {
|
||||
if (box.move(x, y, z).intersects(body)) return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
seedPosition(nativePlayer, new Vec3(8, 64, 8));
|
||||
set(CraftEntity.class, player, "entity", nativePlayer);
|
||||
doReturn(EntityTypes.PLAYER).when(nativePlayer).getType();
|
||||
doCallRealMethod().when(nativePlayer).position();
|
||||
doCallRealMethod().when(nativePlayer).getX();
|
||||
doCallRealMethod().when(nativePlayer).getY();
|
||||
doCallRealMethod().when(nativePlayer).getZ();
|
||||
doCallRealMethod().when(nativePlayer).getYRot();
|
||||
doCallRealMethod().when(nativePlayer).getYHeadRot();
|
||||
doCallRealMethod().when(nativePlayer).setYHeadRot(anyFloat());
|
||||
doCallRealMethod().when(nativePlayer).getXRot();
|
||||
doCallRealMethod().when(nativePlayer).getBukkitYaw();
|
||||
doCallRealMethod().when(nativePlayer).getBoundingBox();
|
||||
when(nativePlayer.getDeltaMovement()).thenReturn(Vec3.ZERO);
|
||||
when(nativePlayer.getKnownMovement()).thenReturn(Vec3.ZERO);
|
||||
doCallRealMethod().when(nativePlayer).setPos(anyDouble(), anyDouble(), anyDouble());
|
||||
doCallRealMethod().when(nativePlayer).setBoundingBox(any(net.minecraft.world.phys.AABB.class));
|
||||
doCallRealMethod().when(nativePlayer).setPosRaw(anyDouble(), anyDouble(), anyDouble());
|
||||
doCallRealMethod().when(nativePlayer).setPosRaw(anyDouble(), anyDouble(), anyDouble(), anyBoolean());
|
||||
doCallRealMethod().when(nativePlayer).setYRot(anyFloat());
|
||||
doCallRealMethod().when(nativePlayer).setXRot(anyFloat());
|
||||
doCallRealMethod().when(nativePlayer).teleportSetPosition(any(PositionMoveRotation.class), anySet());
|
||||
doCallRealMethod().when(nativePlayer).teleportSetPosition(any(PositionMoveRotation.class), any(PositionMoveRotation.class), anySet());
|
||||
doCallRealMethod().when(nativePlayer).teleport(any(TeleportTransition.class));
|
||||
when(nativePlayer.isAlive()).thenReturn(true);
|
||||
nativePlayer.valid = true;
|
||||
nativePlayer.connection = connection;
|
||||
connection.player = nativePlayer;
|
||||
when(connection.hasClientLoaded()).thenReturn(true);
|
||||
doCallRealMethod().when(connection).handleUseItem(any(net.minecraft.network.protocol.game.ServerboundUseItemPacket.class));
|
||||
when(nativePlayer.getEyePosition()).thenAnswer(call -> org.bukkit.craftbukkit.util.CraftVector.toVec3(player.getEyeLocation().toVector()));
|
||||
when(nativePlayer.getViewVector(anyFloat())).thenAnswer(call -> org.bukkit.craftbukkit.util.CraftVector.toVec3(player.getEyeLocation().getDirection()));
|
||||
when(nativePlayer.blockInteractionRange()).thenReturn(4.5);
|
||||
// The finite world supplies the incoming ray result; block collision safety uses actual CraftBlock shapes.
|
||||
when(level.clip(any(net.minecraft.world.level.ClipContext.class))).thenAnswer(call -> {
|
||||
var to = call.getArgument(0, net.minecraft.world.level.ClipContext.class).getTo();
|
||||
return net.minecraft.world.phys.BlockHitResult.miss(to, net.minecraft.core.Direction.SOUTH, BlockPos.containing(to));
|
||||
});
|
||||
doCallRealMethod().when(connection).internalTeleport(any(PositionMoveRotation.class), anySet());
|
||||
doAnswer(call -> { packets.add(call.getArgument(0)); return null; }).when(connection).send(any(ClientboundPlayerPositionPacket.class));
|
||||
doCallRealMethod().when(player).getLocation();
|
||||
doCallRealMethod().when(player).getEyeLocation();
|
||||
doCallRealMethod().when(player).getBoundingBox();
|
||||
doCallRealMethod().when(player).wouldCollideUsing(any(org.bukkit.util.BoundingBox.class));
|
||||
doCallRealMethod().when(player).teleport(any(Location.class), any(PlayerTeleportEvent.TeleportCause.class));
|
||||
doCallRealMethod().when(player).teleport(any(Location.class), any(PlayerTeleportEvent.TeleportCause.class), any(io.papermc.paper.entity.TeleportFlag[].class));
|
||||
nativeInventory = new Inventory(nativePlayer, new EntityEquipment());
|
||||
when(nativePlayer.getInventory()).thenReturn(nativeInventory);
|
||||
when(nativePlayer.getItemInHand(any(InteractionHand.class))).thenAnswer(call ->
|
||||
nativeInventory.getItem(call.getArgument(0) == InteractionHand.MAIN_HAND ? 0 : 40));
|
||||
inventory = new CraftInventoryPlayer(nativeInventory);
|
||||
set(net.minecraft.world.entity.player.Player.class, nativePlayer, "inventoryMenu",
|
||||
new net.minecraft.world.inventory.InventoryMenu(nativeInventory, false, nativePlayer));
|
||||
nativePlayer.containerMenu = nativePlayer.inventoryMenu;
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
menu = new CraftingMenu(0, nativeInventory);
|
||||
var craftingView = menu.getBukkitView();
|
||||
when(player.getOpenInventory()).thenReturn(craftingView);
|
||||
var scheduler = mock(CraftScheduler.class);
|
||||
when(server.getScheduler()).thenReturn(scheduler);
|
||||
when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(call -> { callbacks.add(call.getArgument(1)); return null; });
|
||||
doReturn(List.of(player)).when(server).getOnlinePlayers();
|
||||
when(server.addRecipe(any(ShapedRecipe.class))).thenAnswer(call -> { recipe = call.getArgument(0); return true; });
|
||||
doAnswer(call -> {
|
||||
var event = call.getArgument(0, Event.class);
|
||||
beforeEvent.accept(event);
|
||||
if (event instanceof PlayerTeleportEvent teleport) {
|
||||
teleports.add(teleport);
|
||||
teleport.setCancelled(cancelTeleport);
|
||||
}
|
||||
for (var listener : event.getHandlers().getRegisteredListeners()) listener.callEvent(event);
|
||||
return null;
|
||||
}).when(events).callEvent(any(Event.class));
|
||||
states = StateService.open(new FileStateRepository(directory.resolve("state.bin"))).get(5, TimeUnit.SECONDS);
|
||||
states.update(state -> ProgressAdministration.progress(state, owner, Track.DAGGER, 313, false)).get(5, TimeUnit.SECONDS);
|
||||
crafting = new DaggerCrafting(plugin, states, items, new EchoGameplay(plugin, states, new EchoItems()));
|
||||
register(crafting);
|
||||
register(new DaggerUse(states::snapshot, items, new DaggerCooldown(clock::get), DaggerSafety::safe));
|
||||
crafting.run();
|
||||
states.update(state -> state).get(5, TimeUnit.SECONDS);
|
||||
var callback = callbacks.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(callback, "durable crafting receipt must finish before preview");
|
||||
callback.run();
|
||||
}
|
||||
|
||||
ItemStack craft() {
|
||||
var ingredients = new HashMap<Character, net.minecraft.world.item.crafting.Ingredient>();
|
||||
recipe.getChoiceMap().forEach((symbol, choice) -> ingredients.put(symbol, CraftRecipe.toIngredient(choice, false)));
|
||||
var nativeRecipe = new net.minecraft.world.item.crafting.ShapedRecipe(
|
||||
new net.minecraft.world.item.crafting.Recipe.CommonInfo(true),
|
||||
new net.minecraft.world.item.crafting.CraftingRecipe.CraftingBookInfo(CraftRecipe.getCategory(recipe.getCategory()), recipe.getGroup()),
|
||||
net.minecraft.world.item.crafting.ShapedRecipePattern.of(ingredients, recipe.getShape()), CraftItemStack.asTemplate(recipe.getResult()));
|
||||
var matrix = List.of(Material.HEART_OF_THE_SEA, Material.HEAVY_CORE, Material.HEART_OF_THE_SEA,
|
||||
Material.NETHER_STAR, Material.OMINOUS_TRIAL_KEY, Material.NETHER_STAR,
|
||||
Material.HEART_OF_THE_SEA, Material.DRAGON_EGG, Material.HEART_OF_THE_SEA);
|
||||
for (int i = 0; i < matrix.size(); i++) menu.craftSlots.setItem(i, CraftItemStack.asNMSCopy(new ItemStack(matrix.get(i))));
|
||||
var input = net.minecraft.world.item.crafting.CraftingInput.of(3, 3, menu.craftSlots.getItems());
|
||||
assertTrue(nativeRecipe.matches(input, level));
|
||||
var key = net.minecraft.resources.ResourceKey.<net.minecraft.world.item.crafting.Recipe<?>>create(
|
||||
net.minecraft.core.registries.Registries.RECIPE, net.minecraft.resources.Identifier.fromNamespaceAndPath("spigotignis", "dagger"));
|
||||
var holder = new net.minecraft.world.item.crafting.RecipeHolder<net.minecraft.world.item.crafting.CraftingRecipe>(key, nativeRecipe);
|
||||
var recipes = new net.minecraft.world.item.crafting.RecipeManager(org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry());
|
||||
recipes.recipes = net.minecraft.world.item.crafting.RecipeMap.create(List.of(holder));
|
||||
when(level.recipeAccess()).thenReturn(recipes);
|
||||
menu.craftSlots.setCurrentRecipe(holder);
|
||||
menu.resultSlots.setRecipeUsed(holder);
|
||||
var prepared = CraftEventFactory.callPreCraftEvent(menu.craftSlots, menu.resultSlots, nativeRecipe.assemble(input), menu.getBukkitView(), false);
|
||||
menu.resultSlots.setItem(0, prepared);
|
||||
var craft = new CraftItemEvent(recipe, menu.getBukkitView(), InventoryType.SlotType.RESULT, 0,
|
||||
org.bukkit.event.inventory.ClickType.LEFT, org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);
|
||||
events.callEvent(craft);
|
||||
assertFalse(craft.isCancelled());
|
||||
var result = menu.getResultSlot().remove(1);
|
||||
menu.getResultSlot().onTake(nativePlayer, result);
|
||||
assertTrue(menu.craftSlots.isEmpty(), "native result completion consumes the nine paid ingredients");
|
||||
inventory.setItem(0, CraftItemStack.asBukkitCopy(result));
|
||||
var dagger = inventory.getItem(0);
|
||||
assertTrue(items.identify(dagger).isPresent(), "crafted result must retain its actual durable identity");
|
||||
assertTrue(new TradableRewards(Track.DAGGER).valid(states.snapshot(), owner, items.identify(dagger).orElseThrow()));
|
||||
return dagger;
|
||||
}
|
||||
|
||||
PlayerInteractEvent use() {
|
||||
return CraftEventFactory.callPlayerInteractEvent(nativePlayer, Action.RIGHT_CLICK_AIR,
|
||||
nativeInventory.getItem(0), InteractionHand.MAIN_HAND);
|
||||
}
|
||||
|
||||
void usePacket() {
|
||||
usePacket(InteractionHand.MAIN_HAND);
|
||||
}
|
||||
|
||||
void usePacket(InteractionHand hand) {
|
||||
var packet = new net.minecraft.network.protocol.game.ServerboundUseItemPacket(hand, packets.size() + 1,
|
||||
nativePlayer.getYRot(), nativePlayer.getXRot());
|
||||
packet.handle(connection);
|
||||
}
|
||||
|
||||
CraftPlayer target(double x, double y, double z, float yaw) {
|
||||
var target = mock(CraftPlayer.class);
|
||||
when(target.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(target.isOnline()).thenReturn(true);
|
||||
when(target.getGameMode()).thenReturn(org.bukkit.GameMode.SURVIVAL);
|
||||
when(target.getLocation()).thenAnswer(call -> new Location(world, x, y, z, yaw, 0));
|
||||
when(target.getEyeLocation()).thenAnswer(call -> new Location(world, x, y + 1.62, z, yaw, 0));
|
||||
players.add(target);
|
||||
return target;
|
||||
}
|
||||
|
||||
void position(double x, double y, double z, float yaw, float pitch) throws Exception {
|
||||
seedPosition(nativePlayer, new Vec3(x, y, z));
|
||||
nativePlayer.setYRot(yaw);
|
||||
nativePlayer.setYHeadRot(yaw);
|
||||
nativePlayer.setXRot(pitch);
|
||||
}
|
||||
|
||||
private void register(Listener listener) throws Exception {
|
||||
for (var method : listener.getClass().getMethods()) {
|
||||
var annotation = method.getAnnotation(EventHandler.class);
|
||||
if (annotation == null) continue;
|
||||
var eventType = method.getParameterTypes()[0].asSubclass(Event.class);
|
||||
var handlers = (HandlerList) eventType.getMethod("getHandlerList").invoke(null);
|
||||
handlers.register(new RegisteredListener(listener, EventExecutor.create(method, eventType), annotation.priority(), plugin, annotation.ignoreCancelled()));
|
||||
}
|
||||
}
|
||||
|
||||
private static void seedPosition(ServerPlayer player, Vec3 position) throws Exception {
|
||||
set(Entity.class, player, "position", position);
|
||||
set(Entity.class, player, "dimensions", EntityTypes.PLAYER.getDimensions());
|
||||
set(Entity.class, player, "bb", EntityTypes.PLAYER.getDimensions().makeBoundingBox(position));
|
||||
set(Entity.class, player, "posLock", new Object());
|
||||
set(Entity.class, player, "blockPosition", BlockPos.containing(position));
|
||||
set(Entity.class, player, "chunkPosition", net.minecraft.world.level.ChunkPos.containing(BlockPos.containing(position)));
|
||||
set(Entity.class, player, "levelCallback", net.minecraft.world.level.entity.EntityInLevelCallback.NULL);
|
||||
set(Entity.class, player, "firstTick", true);
|
||||
}
|
||||
|
||||
static void set(Class<?> type, Object target, String name, Object value) throws Exception {
|
||||
var field = type.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
HandlerList.unregisterAll(plugin);
|
||||
crafting.close();
|
||||
states.close();
|
||||
settings.close();
|
||||
platform.close();
|
||||
}
|
||||
|
||||
@FunctionalInterface interface TickWork { void run() throws Exception; }
|
||||
static void onTick(TickWork work) throws Exception {
|
||||
var done = new CompletableFuture<Void>();
|
||||
var thread = new TickThread(() -> {
|
||||
try { work.run(); done.complete(null); }
|
||||
catch (Throwable failure) { done.completeExceptionally(failure); }
|
||||
}, "Ignis native Dagger acceptance");
|
||||
thread.start();
|
||||
try { done.get(30, TimeUnit.SECONDS); }
|
||||
catch (java.util.concurrent.ExecutionException failure) {
|
||||
if (failure.getCause() instanceof AssertionError assertion) throw assertion;
|
||||
if (failure.getCause() instanceof Exception exception) throw exception;
|
||||
throw failure;
|
||||
} finally { thread.join(5000); }
|
||||
assertFalse(thread.isAlive());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.events.PacketContainer;
|
||||
import com.comphenix.protocol.events.PacketEvent;
|
||||
import io.papermc.paper.adventure.PaperAdventure;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.TranslatableComponent;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
import net.minecraft.advancements.Advancement;
|
||||
import net.minecraft.advancements.AdvancementHolder;
|
||||
import net.minecraft.advancements.AdvancementType;
|
||||
import net.minecraft.network.protocol.Packet;
|
||||
import net.minecraft.network.protocol.game.ClientboundSystemChatPacket;
|
||||
import net.minecraft.server.PlayerAdvancements;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.craftbukkit.CraftServer;
|
||||
import org.bukkit.craftbukkit.entity.CraftPlayer;
|
||||
import org.bukkit.craftbukkit.inventory.CraftInventoryPlayer;
|
||||
import org.bukkit.craftbukkit.scheduler.CraftScheduler;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.player.PlayerAdvancementDoneEvent;
|
||||
import org.bukkit.plugin.EventExecutor;
|
||||
import org.bukkit.plugin.RegisteredListener;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class NativeEchoAnnouncementTest {
|
||||
@TempDir Path directory;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.MethodSource("cases")
|
||||
@SuppressWarnings("try")
|
||||
void consumedEchoSurvivesReopenAndMasksNativeAnnouncementDelivery(AdvancementType type, String mode) throws Exception {
|
||||
boolean onlyAffected = !mode.equals("broadcast");
|
||||
var server = mock(CraftServer.class);
|
||||
try (var platform = mockStatic(Bukkit.class, call -> switch (call.getMethod().getName()) {
|
||||
case "getServer" -> server;
|
||||
case "isPrimaryThread" -> true;
|
||||
case "getVersion" -> "Purpur 2618 (MC: 26.2)";
|
||||
case "getMinecraftVersion" -> "26.2";
|
||||
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT";
|
||||
default -> call.callRealMethod();
|
||||
})) {
|
||||
UUID owner = UUID.randomUUID(), receipt = UUID.randomUUID();
|
||||
var token = new EchoToken(owner, receipt, 0);
|
||||
var player = mock(CraftPlayer.class);
|
||||
when(player.getUniqueId()).thenReturn(owner);
|
||||
when(player.getName()).thenReturn("EchoOwner");
|
||||
var nativePlayer = mock(ServerPlayer.class);
|
||||
when(player.getHandle()).thenReturn(nativePlayer);
|
||||
when(nativePlayer.getBukkitEntity()).thenReturn(player);
|
||||
var inventory = mock(CraftInventoryPlayer.class);
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
when(inventory.getSize()).thenReturn(41);
|
||||
var items = new EchoItems();
|
||||
var slot = new AtomicReference<>(items.create(token));
|
||||
when(inventory.getItem(0)).thenAnswer(call -> slot.get());
|
||||
doAnswer(call -> { slot.set(call.getArgument(1)); return null; }).when(inventory).setItem(eq(0), any());
|
||||
var plugin = mock(JavaPlugin.class);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(server.getPlayer(owner)).thenReturn(player);
|
||||
var scheduler = mock(CraftScheduler.class);
|
||||
when(server.getScheduler()).thenReturn(scheduler);
|
||||
var callbacks = new LinkedBlockingQueue<Runnable>();
|
||||
when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(call -> { callbacks.add(call.getArgument(1)); return null; });
|
||||
var repository = new FileStateRepository(directory.resolve("state.bin"));
|
||||
try (var states = StateService.open(repository).get(5, TimeUnit.SECONDS)) {
|
||||
states.update(state -> EchoRewards.award(state, owner, receipt)).get(5, TimeUnit.SECONDS);
|
||||
var gameplay = new EchoGameplay(plugin, states, items);
|
||||
var use = new org.bukkit.event.player.PlayerInteractEvent(player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
slot.get(), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
gameplay.onUse(use);
|
||||
var callback = callbacks.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(callback);
|
||||
callback.run();
|
||||
assertNull(slot.get(), "happy-path consumption must remove the real receipt item");
|
||||
assertTrue(gameplay.activated(owner));
|
||||
}
|
||||
try (var states = StateService.open(repository).get(5, TimeUnit.SECONDS)) {
|
||||
var gameplay = new EchoGameplay(plugin, states, items);
|
||||
assertTrue(gameplay.activated(owner), "saved activation must survive service reopen");
|
||||
if (mode.equals("reset")) { states.update(state -> ProgressAdministration.reset(state, owner, java.util.Set.of(Track.VOID))).get(5, TimeUnit.SECONDS); }
|
||||
if (mode.equals("ordinary")) { when(player.getUniqueId()).thenReturn(UUID.randomUUID()); }
|
||||
var listener = new AnnouncementListener(gameplay::activated);
|
||||
var warnings = new ArrayList<String>();
|
||||
var packets = new AnnouncementPacketListener(plugin, gameplay::activated, warnings::add);
|
||||
var delivered = new ArrayList<Component>();
|
||||
nativePlayer.connection = mock(net.minecraft.server.network.ServerGamePacketListenerImpl.class);
|
||||
org.mockito.stubbing.Answer<Void> capture = call -> {
|
||||
var raw = call.getArgument(0, ClientboundSystemChatPacket.class);
|
||||
var outgoing = PacketEvent.fromServer(this, new PacketContainer(PacketType.Play.Server.SYSTEM_CHAT, raw), player);
|
||||
if (mode.equals("wire-cancelled")) { outgoing.setCancelled(true); }
|
||||
var before = raw.content();
|
||||
packets.onPacketSending(outgoing);
|
||||
assertSame(before, raw.content(), "shared native packet content must not be mutated");
|
||||
if (!outgoing.isCancelled()) {
|
||||
var projected = (ClientboundSystemChatPacket) outgoing.getPacket().getHandle();
|
||||
var buffer = new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(), org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry());
|
||||
try {
|
||||
ClientboundSystemChatPacket.STREAM_CODEC.encode(buffer, projected);
|
||||
var decoded = ClientboundSystemChatPacket.STREAM_CODEC.decode(buffer);
|
||||
assertFalse(decoded.overlay());
|
||||
delivered.add(PaperAdventure.asAdventure(decoded.content()));
|
||||
} finally { buffer.release(); }
|
||||
}
|
||||
return null;
|
||||
};
|
||||
doAnswer(capture).when(nativePlayer.connection).send(org.mockito.ArgumentMatchers.<Packet<?>>any());
|
||||
doAnswer(capture).when(nativePlayer.connection).send(org.mockito.ArgumentMatchers.<Packet<?>>any(), any(io.netty.channel.ChannelFutureListener.class));
|
||||
doCallRealMethod().when(player).sendMessage(any(Component.class));
|
||||
doCallRealMethod().when(nativePlayer).sendMessage(any(Component.class));
|
||||
doCallRealMethod().when(nativePlayer).sendSystemMessage(any(net.minecraft.network.chat.Component.class));
|
||||
doCallRealMethod().when(nativePlayer).sendSystemMessage(any(net.minecraft.network.chat.Component.class), anyBoolean());
|
||||
var nativeWorld = mock(net.minecraft.server.level.ServerLevel.class);
|
||||
when(nativePlayer.level()).thenReturn(nativeWorld);
|
||||
var rules = mock(net.minecraft.world.level.gamerules.GameRules.class);
|
||||
when(rules.get(net.minecraft.world.level.gamerules.GameRules.SHOW_ADVANCEMENT_MESSAGES)).thenReturn(!mode.equals("rule-off"));
|
||||
when(nativeWorld.getGameRules()).thenReturn(rules);
|
||||
var players = mock(net.minecraft.server.players.PlayerList.class);
|
||||
doAnswer(call -> { player.sendMessage(PaperAdventure.asAdventure(call.getArgument(0, net.minecraft.network.chat.Component.class))); return null; })
|
||||
.when(players).broadcastSystemMessage(any(net.minecraft.network.chat.Component.class), eq(false));
|
||||
var advancements = mock(PlayerAdvancements.class);
|
||||
set(advancements, "player", nativePlayer);
|
||||
set(advancements, "playerList", players);
|
||||
var name = Component.text("EchoOwner").hoverEvent(net.kyori.adventure.text.event.HoverEvent.showEntity(
|
||||
net.kyori.adventure.key.Key.key("minecraft:player"), player.getUniqueId(), Component.text("EchoOwner")));
|
||||
when(nativePlayer.getDisplayName()).thenReturn(PaperAdventure.asVanilla(name));
|
||||
var title = Component.text("EchoOwner's Challenge").hoverEvent(Component.text("Keep the description"));
|
||||
var advancement = new Advancement(Optional.empty(), Optional.empty(), net.minecraft.advancements.AdvancementRewards.EMPTY,
|
||||
Map.of(), net.minecraft.advancements.AdvancementRequirements.EMPTY, false, Optional.of(PaperAdventure.asVanilla(title)));
|
||||
var holder = new AdvancementHolder(net.minecraft.resources.Identifier.withDefaultNamespace("echo_regression"), advancement);
|
||||
var original = PaperAdventure.asAdventure(type.createAnnouncement(holder, nativePlayer));
|
||||
var event = new PlayerAdvancementDoneEvent(player, holder.toBukkit(), original);
|
||||
if (mode.equals("silent")) { event.message(null); }
|
||||
var method = AnnouncementListener.class.getMethod("onAdvancement", PlayerAdvancementDoneEvent.class);
|
||||
var annotation = method.getAnnotation(EventHandler.class);
|
||||
new RegisteredListener(listener, EventExecutor.create(method, PlayerAdvancementDoneEvent.class), annotation.priority(), plugin, annotation.ignoreCancelled()).callEvent(event);
|
||||
var nativeDelivery = PlayerAdvancements.class.getDeclaredMethod("lambda$award$1", PlayerAdvancementDoneEvent.class, Component.class, net.minecraft.advancements.DisplayInfo.class);
|
||||
nativeDelivery.setAccessible(true);
|
||||
boolean previous = org.purpurmc.purpur.PurpurConfig.advancementOnlyBroadcastToAffectedPlayer;
|
||||
try {
|
||||
org.purpurmc.purpur.PurpurConfig.advancementOnlyBroadcastToAffectedPlayer = onlyAffected;
|
||||
nativeDelivery.invoke(advancements, event, original, null);
|
||||
} finally { org.purpurmc.purpur.PurpurConfig.advancementOnlyBroadcastToAffectedPlayer = previous; }
|
||||
if (java.util.Set.of("silent", "rule-off", "wire-cancelled").contains(mode)) {
|
||||
assertTrue(delivered.isEmpty());
|
||||
assertTrue(warnings.isEmpty());
|
||||
return;
|
||||
}
|
||||
assertEquals(1, delivered.size());
|
||||
var message = assertInstanceOf(TranslatableComponent.class, delivered.getFirst());
|
||||
var alias = assertInstanceOf(Component.class, message.arguments().getFirst().value());
|
||||
boolean eligible = !java.util.Set.of("ordinary", "reset").contains(mode);
|
||||
assertEquals(eligible ? TextDecoration.State.TRUE : TextDecoration.State.NOT_SET, alias.decoration(TextDecoration.OBFUSCATED), "native delivery must honor current activation");
|
||||
if (eligible) { assertNull(alias.hoverEvent()); }
|
||||
else { assertEquals(name, alias); }
|
||||
assertEquals(title, message.arguments().get(1).value());
|
||||
assertTrue(warnings.isEmpty(), warnings.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static java.util.stream.Stream<org.junit.jupiter.params.provider.Arguments> cases() {
|
||||
return java.util.Arrays.stream(AdvancementType.values()).flatMap(type -> java.util.stream.Stream.of(
|
||||
"broadcast", "only-affected", "ordinary", "reset", "silent", "rule-off", "wire-cancelled")
|
||||
.map(mode -> org.junit.jupiter.params.provider.Arguments.of(type, mode)));
|
||||
}
|
||||
|
||||
private static void set(PlayerAdvancements target, String name, Object value) throws Exception {
|
||||
var field = PlayerAdvancements.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.entity.EntityEquipment;
|
||||
import net.minecraft.world.entity.player.Inventory;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.craftbukkit.CraftServer;
|
||||
import org.bukkit.craftbukkit.entity.CraftPlayer;
|
||||
import org.bukkit.craftbukkit.inventory.CraftInventoryPlayer;
|
||||
import org.bukkit.craftbukkit.scheduler.CraftScheduler;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
|
||||
class NativeEchoLifecycleTest {
|
||||
@TempDir Path directory;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"0,1", "0,3", "40,1", "40,3"})
|
||||
void actualInventoryConsumesExactlyOneOnceAndReopensActivated(int slot, int amount) throws Exception {
|
||||
try (var f = new Fixture(directory)) {
|
||||
var echo = f.items.create(f.token);
|
||||
echo.setAmount(amount);
|
||||
f.inventory.setItem(slot, echo);
|
||||
var use = f.use(slot);
|
||||
f.gameplay.onUse(use);
|
||||
f.gameplay.onUse(use);
|
||||
f.callback();
|
||||
assertTrue(f.gameplay.activated(f.owner));
|
||||
assertEquals(amount - 1, f.count());
|
||||
f.gameplay.onUse(use);
|
||||
assertEquals(amount - 1, f.count());
|
||||
assertFalse(EchoRewards.valid(f.states.snapshot(), f.owner, f.token));
|
||||
try (var reopened = StateService.open(f.disk).get(5, TimeUnit.SECONDS)) {
|
||||
assertTrue(new EchoGameplay(f.plugin, reopened, f.items).activated(f.owner));
|
||||
assertFalse(EchoRewards.valid(reopened.snapshot(), f.owner, f.token));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"disconnect", "death", "disable"})
|
||||
void interruptedCopiesRemainInertAfterReopen(String interruption) throws Exception {
|
||||
try (var f = new Fixture(directory)) {
|
||||
var copy = f.items.create(f.token);
|
||||
f.inventory.setItem(0, copy);
|
||||
f.gameplay.onUse(f.use(0));
|
||||
var callback = f.awaitCallback();
|
||||
assertTrue(f.gameplay.activated(f.owner));
|
||||
if (interruption.equals("disconnect")) {
|
||||
var quit = mock(org.bukkit.event.player.PlayerQuitEvent.class);
|
||||
when(quit.getPlayer()).thenReturn(f.player);
|
||||
f.gameplay.onQuit(quit);
|
||||
when(f.server.getPlayer(f.owner)).thenReturn(null);
|
||||
callback.run();
|
||||
assertEquals(1, f.count());
|
||||
} else if (interruption.equals("death")) {
|
||||
// The world/drop boundary removes the item before the pending callback.
|
||||
f.inventory.clear();
|
||||
var death = mock(org.bukkit.event.entity.PlayerDeathEvent.class);
|
||||
when(death.getEntity()).thenReturn(f.player);
|
||||
f.gameplay.onDeath(death);
|
||||
callback.run();
|
||||
f.inventory.setItem(0, copy); // later pickup of the retained drop
|
||||
} else {
|
||||
f.enabled.set(false); // Bukkit cancels the queued callback on disable; do not execute it.
|
||||
assertEquals(1, f.count());
|
||||
}
|
||||
try (var reopened = StateService.open(f.disk).get(5, TimeUnit.SECONDS)) {
|
||||
var resumed = new EchoGameplay(f.plugin, reopened, f.items);
|
||||
f.enabled.set(true);
|
||||
when(f.server.getPlayer(f.owner)).thenReturn(f.player);
|
||||
assertTrue(resumed.activated(f.owner));
|
||||
resumed.onUse(f.use(0));
|
||||
assertEquals(1, f.count(), "interrupted copies are inert, not consumed a second time");
|
||||
assertFalse(EchoRewards.valid(reopened.snapshot(), f.owner, f.token));
|
||||
assertTrue(f.callbacks.isEmpty(), "a spent receipt cannot schedule another activation");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"foreign", "unknown", "reset", "denied"})
|
||||
void onlyTheCurrentOwnerReceiptCanActivateAndProtectionIsRespected(String rejection) throws Exception {
|
||||
try (var f = new Fixture(directory)) {
|
||||
var token = rejection.equals("unknown") ? new EchoToken(f.owner, UUID.randomUUID(), 0) : f.token;
|
||||
var echo = f.items.create(token);
|
||||
f.inventory.setItem(0, echo);
|
||||
if (rejection.equals("foreign")) { when(f.player.getUniqueId()).thenReturn(UUID.randomUUID()); }
|
||||
if (rejection.equals("reset")) {
|
||||
f.states.update(state -> ProgressAdministration.reset(state, f.owner, java.util.Set.of(Track.VOID))).get(5, TimeUnit.SECONDS);
|
||||
f.states.update(state -> EchoRewards.award(state, f.owner, UUID.randomUUID())).get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
var use = f.use(0);
|
||||
if (rejection.equals("denied")) { use.setUseItemInHand(org.bukkit.event.Event.Result.DENY); }
|
||||
f.gameplay.onUse(use);
|
||||
assertFalse(f.gameplay.activated(f.owner));
|
||||
assertEquals(echo, f.inventory.getItem(0));
|
||||
assertTrue(f.callbacks.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.api.Test
|
||||
void failedActivationLeavesRealItemsUnconsumedAndCanBeRetried() throws Exception {
|
||||
try (var f = new Fixture(directory)) {
|
||||
var echo = f.items.create(f.token);
|
||||
echo.setAmount(3);
|
||||
f.inventory.setItem(0, echo);
|
||||
f.failSave.set(true);
|
||||
f.gameplay.onUse(f.use(0));
|
||||
f.callback();
|
||||
assertFalse(f.gameplay.activated(f.owner));
|
||||
assertEquals(3, f.count());
|
||||
verify(f.player).sendMessage(contains("It has not been consumed; try again"));
|
||||
f.failSave.set(false);
|
||||
f.gameplay.onUse(f.use(0));
|
||||
f.callback();
|
||||
assertTrue(f.gameplay.activated(f.owner));
|
||||
assertEquals(2, f.count());
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.api.Test
|
||||
void actualFullInventoryDefersAndFailedAcknowledgementRetriesWithoutAnotherOffer() throws Exception {
|
||||
try (var f = new Fixture(directory)) {
|
||||
for (int slot = 0; slot < 36; slot++) {
|
||||
f.inventory.setItem(slot, new org.bukkit.inventory.ItemStack(org.bukkit.Material.COBBLESTONE, 64));
|
||||
}
|
||||
f.gameplay.retryDeliveries();
|
||||
assertEquals(0, f.count());
|
||||
assertTrue(f.states.snapshot().players().get(f.owner).pendingRewards().containsKey(f.token.receipt()));
|
||||
assertTrue(f.callbacks.isEmpty());
|
||||
f.inventory.setItem(5, null);
|
||||
f.failSave.set(true);
|
||||
f.gameplay.retryDeliveries();
|
||||
f.gameplay.retryDeliveries();
|
||||
f.callback();
|
||||
assertEquals(1, f.count());
|
||||
assertTrue(f.states.snapshot().players().get(f.owner).pendingRewards().containsKey(f.token.receipt()));
|
||||
f.failSave.set(false);
|
||||
f.gameplay.retryDeliveries();
|
||||
f.callback();
|
||||
f.gameplay.retryDeliveries();
|
||||
assertEquals(1, f.count());
|
||||
assertTrue(f.states.snapshot().players().get(f.owner).pendingRewards().isEmpty());
|
||||
try (var reopened = StateService.open(f.disk).get(5, TimeUnit.SECONDS)) {
|
||||
assertTrue(reopened.snapshot().players().get(f.owner).pendingRewards().isEmpty());
|
||||
assertTrue(EchoRewards.valid(reopened.snapshot(), f.owner, f.token));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Fixture implements AutoCloseable {
|
||||
final UUID owner = UUID.randomUUID();
|
||||
final EchoToken token = new EchoToken(owner, UUID.randomUUID(), 0);
|
||||
final EchoItems items = new EchoItems();
|
||||
final CraftServer server = mock(CraftServer.class);
|
||||
final org.mockito.MockedStatic<Bukkit> platform;
|
||||
final JavaPlugin plugin = mock(JavaPlugin.class);
|
||||
final CraftPlayer player = mock(CraftPlayer.class);
|
||||
final CraftInventoryPlayer inventory;
|
||||
final LinkedBlockingQueue<Runnable> callbacks = new LinkedBlockingQueue<>();
|
||||
final AtomicBoolean enabled = new AtomicBoolean(true), failSave = new AtomicBoolean();
|
||||
final FileStateRepository disk;
|
||||
final StateService states;
|
||||
final EchoGameplay gameplay;
|
||||
Fixture(Path directory) throws Exception {
|
||||
platform = mockStatic(Bukkit.class, call -> switch (call.getMethod().getName()) {
|
||||
case "getServer" -> server;
|
||||
case "isPrimaryThread" -> true;
|
||||
default -> call.callRealMethod();
|
||||
});
|
||||
var nativePlayer = mock(ServerPlayer.class);
|
||||
when(nativePlayer.getBukkitEntity()).thenReturn(player);
|
||||
when(player.getHandle()).thenReturn(nativePlayer);
|
||||
inventory = new CraftInventoryPlayer(new Inventory(nativePlayer, new EntityEquipment()));
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
when(player.getUniqueId()).thenReturn(owner);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
|
||||
when(plugin.isEnabled()).thenAnswer(call -> enabled.get());
|
||||
when(server.getPlayer(owner)).thenReturn(player);
|
||||
doReturn(List.of(player)).when(server).getOnlinePlayers();
|
||||
var scheduler = mock(CraftScheduler.class);
|
||||
when(server.getScheduler()).thenReturn(scheduler);
|
||||
when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(call -> { callbacks.add(call.getArgument(1)); return null; });
|
||||
disk = new FileStateRepository(directory.resolve("state.bin"));
|
||||
states = StateService.open(new StateRepository() {
|
||||
@Override public IgnisState load() throws IOException { return disk.load(); }
|
||||
@Override public void save(IgnisState state) throws IOException {
|
||||
if (failSave.get()) { throw new IOException("simulated unavailable storage"); }
|
||||
disk.save(state);
|
||||
}
|
||||
}).get(5, TimeUnit.SECONDS);
|
||||
states.update(state -> EchoRewards.award(state, owner, token.receipt())).get(5, TimeUnit.SECONDS);
|
||||
gameplay = new EchoGameplay(plugin, states, items);
|
||||
}
|
||||
PlayerInteractEvent use(int slot) {
|
||||
return new PlayerInteractEvent(player, Action.RIGHT_CLICK_AIR, inventory.getItem(slot), null,
|
||||
org.bukkit.block.BlockFace.SELF, slot == 40 ? EquipmentSlot.OFF_HAND : EquipmentSlot.HAND);
|
||||
}
|
||||
Runnable awaitCallback() throws InterruptedException {
|
||||
var callback = callbacks.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(callback);
|
||||
return callback;
|
||||
}
|
||||
void callback() throws InterruptedException { awaitCallback().run(); }
|
||||
int count() {
|
||||
return java.util.Arrays.stream(inventory.getContents()).filter(item -> items.identify(item).filter(token::equals).isPresent())
|
||||
.mapToInt(org.bukkit.inventory.ItemStack::getAmount).sum();
|
||||
}
|
||||
@Override public void close() { states.close(); platform.close(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.core.LayeredRegistryAccess;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.RegistryDataLoader;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.RegistryLayer;
|
||||
import net.minecraft.server.ReloadableServerResources;
|
||||
import net.minecraft.server.packs.PackType;
|
||||
import net.minecraft.server.packs.repository.ServerPacksSource;
|
||||
import net.minecraft.server.packs.resources.MultiPackResourceManager;
|
||||
import net.minecraft.server.permissions.LevelBasedPermissionSet;
|
||||
import net.minecraft.tags.TagLoader;
|
||||
import net.minecraft.util.Util;
|
||||
import net.minecraft.world.flag.FeatureFlags;
|
||||
import net.minecraft.world.level.DataPackConfig;
|
||||
import net.minecraft.world.level.WorldDataConfiguration;
|
||||
import org.bukkit.craftbukkit.CraftRegistry;
|
||||
|
||||
/** Vanilla registry/tag/component bootstrap, shared in approach with the other Purpur native suites. */
|
||||
final class NativeRuntime {
|
||||
private NativeRuntime() { }
|
||||
private static boolean ready;
|
||||
static synchronized void bootstrap() throws Exception {
|
||||
if (ready) { return; }
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
var flags = FeatureFlags.VANILLA_SET;
|
||||
var packs = ServerPacksSource.createVanillaTrustedRepository();
|
||||
MinecraftServer.configurePackRepository(packs, new WorldDataConfiguration(new DataPackConfig(
|
||||
FeatureFlags.REGISTRY.toNames(flags).stream().map(Identifier::getPath).toList(), List.of()), flags), true, false);
|
||||
try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) {
|
||||
LayeredRegistryAccess<RegistryLayer> layers = RegistryLayer.createRegistryAccess();
|
||||
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC));
|
||||
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLDGEN), tags);
|
||||
RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups,
|
||||
RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join();
|
||||
layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen);
|
||||
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
|
||||
Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(),
|
||||
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
|
||||
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
|
||||
Class.forName(org.bukkit.Registry.class.getName());
|
||||
var datapack = ReloadableServerResources.loadResources(resources, layers, tags, flags,
|
||||
Commands.CommandSelection.DEDICATED, LevelBasedPermissionSet.ALL_PERMISSIONS,
|
||||
Util.backgroundExecutor(), Runnable::run).join();
|
||||
datapack.updateComponentsAndStaticRegistryTags();
|
||||
CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze());
|
||||
}
|
||||
ready = true;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,27 @@ class AdvancementMaskerTest {
|
||||
assertNull(AdvancementMasker.mask(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void packetFallbackUsesOnlyAnUnambiguousPlayerIdentityInTheNameSlot() {
|
||||
var active = java.util.UUID.randomUUID();
|
||||
var other = java.util.UUID.randomUUID();
|
||||
var name = Component.text("Player").hoverEvent(net.kyori.adventure.text.event.HoverEvent.showEntity(
|
||||
net.kyori.adventure.key.Key.key("minecraft:player"), active, Component.text("Player")));
|
||||
var ordinary = Component.text("Player").hoverEvent(net.kyori.adventure.text.event.HoverEvent.showEntity(
|
||||
net.kyori.adventure.key.Key.key("minecraft:player"), other, Component.text("Player")));
|
||||
var title = Component.text("Player's title").hoverEvent(name);
|
||||
var announcement = Component.translatable("chat.type.advancement.task", name, title);
|
||||
assertEquals(AdvancementMasker.mask(announcement), AdvancementMasker.maskIdentified(announcement, active::equals));
|
||||
for (var candidate : java.util.List.<Component>of(
|
||||
Component.translatable("chat.type.advancement.task", ordinary, title),
|
||||
Component.translatable("chat.type.advancement.task", Component.text("Player"), title),
|
||||
Component.translatable("chat.type.advancement.task", Component.empty().append(name).append(ordinary), title),
|
||||
Component.translatable("chat.type.text", name, title),
|
||||
Component.text("A custom Player announcement"))) {
|
||||
assertSame(candidate, AdvancementMasker.maskIdentified(candidate, active::equals));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void replacesOnlyAdvancementNameAndPreservesAdvancementDetails() {
|
||||
var name = Component.text("RealPlayer").clickEvent(ClickEvent.suggestCommand("/msg RealPlayer "))
|
||||
|
||||
@@ -14,7 +14,7 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
class DaggerSafetyTest {
|
||||
@Test
|
||||
void requiresClearLoadedSupportedInBorderDestinationWithoutMutatingPlayerBounds() {
|
||||
void requiresClearLoadedInBorderDestinationWithoutGroundOrMutatingPlayerBounds() {
|
||||
var world = mock(World.class);
|
||||
var border = mock(WorldBorder.class);
|
||||
when(world.getWorldBorder()).thenReturn(border);
|
||||
@@ -33,7 +33,6 @@ class DaggerSafetyTest {
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(player.getLocation()).thenReturn(new Location(world, 0, 64, 0));
|
||||
when(player.getBoundingBox()).thenReturn(body);
|
||||
when(player.wouldCollideUsing(any())).thenReturn(true); // Supporting floor beneath candidate.
|
||||
var destination = new Location(world, 0, 64, 3);
|
||||
assertTrue(DaggerSafety.safe(player, destination));
|
||||
var protruding = mock(Block.class);
|
||||
@@ -45,9 +44,7 @@ class DaggerSafetyTest {
|
||||
assertFalse(DaggerSafety.safe(player, destination), "Neighbor collision shapes can protrude outside their block cell");
|
||||
when(world.getBlockAt(1, 64, 0)).thenReturn(air);
|
||||
assertEquals(new BoundingBox(-.3, 64, -.3, .3, 65.8, .3), body);
|
||||
when(player.wouldCollideUsing(any())).thenReturn(false);
|
||||
assertFalse(DaggerSafety.safe(player, destination));
|
||||
when(player.wouldCollideUsing(any())).thenReturn(true);
|
||||
assertTrue(DaggerSafety.safe(player, destination), "unsupported air is a valid exit");
|
||||
when(air.getType()).thenReturn(Material.LAVA);
|
||||
assertFalse(DaggerSafety.safe(player, destination));
|
||||
when(air.getType()).thenReturn(Material.AIR);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class DarknessAdministrationTest {
|
||||
@TempDir Path directory;
|
||||
@Test
|
||||
void editsFlushElapsedProgressAndRebaseRatherThanReplayingTheOldInterval() throws Exception {
|
||||
for (boolean add : new boolean[]{false, true}) {
|
||||
try (var states = StateService.open(new FileStateRepository(directory.resolve(add + ".bin"))).get()) {
|
||||
var clock = new AtomicLong();
|
||||
var tracker = new DarknessProgress(states, clock::get);
|
||||
var id = UUID.randomUUID();
|
||||
tracker.observe(id, true);
|
||||
clock.set(100_000_000L);
|
||||
tracker.observe(id, true);
|
||||
tracker.edit(id, 500, add).get();
|
||||
tracker.observe(id, true);
|
||||
tracker.checkpoint(id).get();
|
||||
assertEquals(add ? 600 : 500, states.snapshot().players().get(id).track(Track.HEART).progress());
|
||||
tracker.interrupt(id);
|
||||
tracker.checkpoint(id).get();
|
||||
assertEquals(0, states.snapshot().players().get(id).track(Track.HEART).progress());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class DarknessBatchTest {
|
||||
@Test
|
||||
void periodicCallsCoalesceButShutdownQueuesFreshInterruptionsBehindAnInflightSave() throws Exception {
|
||||
var started = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var writes = new AtomicInteger();
|
||||
var repository = new StateRepository() {
|
||||
@Override public IgnisState load() { return IgnisState.empty(); }
|
||||
@Override public void save(IgnisState state) throws java.io.IOException {
|
||||
if (writes.incrementAndGet() == 1) {
|
||||
started.countDown();
|
||||
try {
|
||||
if (!release.await(3, java.util.concurrent.TimeUnit.SECONDS)) throw new java.io.IOException("fixture timeout");
|
||||
} catch (InterruptedException error) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new java.io.IOException(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
try (var states = StateService.open(repository).get()) {
|
||||
var clock = new AtomicLong();
|
||||
var tracker = new DarknessProgress(states, clock::get);
|
||||
var id = UUID.randomUUID();
|
||||
tracker.observe(id, true);
|
||||
clock.set(100_000_000L);
|
||||
tracker.observe(id, true);
|
||||
var first = tracker.checkpointAll();
|
||||
try {
|
||||
assertTrue(started.await(3, java.util.concurrent.TimeUnit.SECONDS));
|
||||
clock.set(200_000_000L);
|
||||
tracker.observe(id, true);
|
||||
assertSame(first, tracker.checkpointAll());
|
||||
var stopping = tracker.stopAll();
|
||||
assertNotSame(first, stopping);
|
||||
release.countDown();
|
||||
stopping.get();
|
||||
assertEquals(2, writes.get());
|
||||
assertEquals(0, states.snapshot().players().get(id).track(Track.HEART).progress());
|
||||
} finally { release.countDown(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkpointsAndShutdownUseOneWriteForAllRememberedPlayers() throws Exception {
|
||||
var writes = new AtomicInteger();
|
||||
var repository = new StateRepository() {
|
||||
@Override public IgnisState load() { return IgnisState.empty(); }
|
||||
@Override public void save(IgnisState state) { writes.incrementAndGet(); }
|
||||
};
|
||||
try (var states = StateService.open(repository).get()) {
|
||||
var clock = new AtomicLong();
|
||||
var tracker = new DarknessProgress(states, clock::get);
|
||||
var players = java.util.stream.IntStream.range(0, 20).mapToObj(ignored -> UUID.randomUUID()).toList();
|
||||
players.forEach(id -> tracker.observe(id, true));
|
||||
clock.set(100_000_000L);
|
||||
players.forEach(id -> tracker.observe(id, true));
|
||||
tracker.checkpointAll().get();
|
||||
assertEquals(1, writes.get());
|
||||
for (var id : players) assertEquals(100, states.snapshot().players().get(id).track(Track.HEART).progress());
|
||||
tracker.stopAll().get();
|
||||
assertEquals(2, writes.get());
|
||||
for (var id : players) assertEquals(0, states.snapshot().players().get(id).track(Track.HEART).progress());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class DarknessProgressTest {
|
||||
@TempDir Path directory;
|
||||
@Test
|
||||
void shutdownClearsUnfinishedStreakAndFailedResetRemainsRetryable() throws Exception {
|
||||
var disk = new FileStateRepository(directory.resolve("shutdown.bin"));
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(false);
|
||||
StateRepository boundary = new StateRepository() {
|
||||
@Override public IgnisState load() throws java.io.IOException { return disk.load(); }
|
||||
@Override public void save(IgnisState state) throws java.io.IOException {
|
||||
if (fail.get()) throw new java.io.IOException("Simulated storage failure");
|
||||
disk.save(state);
|
||||
}
|
||||
};
|
||||
var clock = new AtomicLong();
|
||||
var id = UUID.randomUUID();
|
||||
try (var states = StateService.open(boundary).get()) {
|
||||
var tracker = new DarknessProgress(states, clock::get);
|
||||
tracker.observe(id, true);
|
||||
clock.set(500_000_000L);
|
||||
tracker.observe(id, true);
|
||||
tracker.checkpoint(id).get();
|
||||
fail.set(true);
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () -> tracker.stopAll().get());
|
||||
assertEquals(500, states.snapshot().players().get(id).track(Track.HEART).progress());
|
||||
fail.set(false);
|
||||
tracker.checkpointAll().get();
|
||||
assertEquals(0, states.snapshot().players().get(id).track(Track.HEART).progress());
|
||||
clock.set(10_000_000_000L);
|
||||
tracker.observe(id, true);
|
||||
tracker.checkpointAll().get();
|
||||
assertEquals(0, states.snapshot().players().get(id).track(Track.HEART).progress());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void queuedOldIntervalCannotRestoreProgressAfterInterruptionOrAdministratorReset() throws Exception {
|
||||
for (boolean adminReset : new boolean[]{false, true}) {
|
||||
var clock = new AtomicLong();
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var id = UUID.randomUUID();
|
||||
try (var states = StateService.open(new FileStateRepository(directory.resolve(adminReset + ".bin"))).get()) {
|
||||
var tracker = new DarknessProgress(states, clock::get);
|
||||
tracker.observe(id, true);
|
||||
clock.set(500_000_000L);
|
||||
tracker.observe(id, true);
|
||||
var blocker = states.update(state -> {
|
||||
entered.countDown();
|
||||
try { if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("Timed out"); }
|
||||
catch (InterruptedException error) { Thread.currentThread().interrupt(); throw new IllegalStateException(error); }
|
||||
return state;
|
||||
});
|
||||
try {
|
||||
assertTrue(entered.await(2, java.util.concurrent.TimeUnit.SECONDS));
|
||||
if (adminReset) states.update(state -> ProgressAdministration.reset(state, id, java.util.Set.of(Track.HEART)));
|
||||
var stale = tracker.checkpoint(id);
|
||||
if (!adminReset) tracker.interrupt(id);
|
||||
release.countDown();
|
||||
blocker.get(); stale.get();
|
||||
assertEquals(0, states.snapshot().players().getOrDefault(id, PlayerState.empty()).track(Track.HEART).progress());
|
||||
tracker.observe(id, true);
|
||||
clock.addAndGet(200_000_000L);
|
||||
tracker.observe(id, true);
|
||||
tracker.checkpoint(id).get();
|
||||
assertEquals(200, states.snapshot().players().get(id).track(Track.HEART).progress());
|
||||
} finally { release.countDown(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void savesContinuousProgressResetsInterruptionsAndRetainsCompletedUnlock() throws Exception {
|
||||
var clock = new AtomicLong();
|
||||
UUID id = UUID.randomUUID();
|
||||
try (var states = StateService.open(new FileStateRepository(directory.resolve("state.bin"))).get()) {
|
||||
states.configure(1000, 313, 1000).get();
|
||||
var tracker = new DarknessProgress(states, clock::get);
|
||||
tracker.observe(id, true);
|
||||
clock.set(500_000_000L);
|
||||
tracker.observe(id, true);
|
||||
tracker.checkpoint(id).get();
|
||||
assertEquals(500, states.snapshot().players().getOrDefault(id, PlayerState.empty()).track(Track.HEART).progress());
|
||||
tracker.interrupt(id);
|
||||
tracker.checkpoint(id).get();
|
||||
assertEquals(0, states.snapshot().players().get(id).track(Track.HEART).progress());
|
||||
tracker.observe(id, true);
|
||||
clock.addAndGet(1_000_000_000L);
|
||||
tracker.observe(id, true);
|
||||
tracker.checkpoint(id).get();
|
||||
assertTrue(states.snapshot().players().get(id).track(Track.HEART).unlocked());
|
||||
tracker.interrupt(id);
|
||||
tracker.checkpoint(id).get();
|
||||
assertTrue(states.snapshot().players().get(id).track(Track.HEART).unlocked());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class DarknessStreakTest {
|
||||
@Test
|
||||
void administratorSeedCountsOnlyUntilAnUnfinishedStreakIsInterrupted() {
|
||||
var time = new AtomicLong();
|
||||
var streak = new DarknessStreak(time::get, 500);
|
||||
assertEquals(500, streak.sample(true, 1000).millis());
|
||||
time.set(200_000_000L);
|
||||
assertEquals(700, streak.sample(true, 1000).millis());
|
||||
assertEquals(0, streak.interrupt().millis());
|
||||
time.set(900_000_000L);
|
||||
assertEquals(0, streak.sample(true, 1000).millis());
|
||||
assertThrows(IllegalArgumentException.class, () -> new DarknessStreak(time::get, -1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiresOneUninterruptedSixHourStreakAndRetainsCompletedUnlock() {
|
||||
long hour = 3_600_000;
|
||||
var time = new AtomicLong();
|
||||
var streak = new DarknessStreak(time::get);
|
||||
assertEquals(new DarknessStreak.Progress(0, false), streak.sample(true, 6 * hour));
|
||||
time.set(3 * hour * 1_000_000);
|
||||
assertEquals(new DarknessStreak.Progress(3 * hour, false), streak.sample(true, 6 * hour));
|
||||
assertEquals(new DarknessStreak.Progress(0, false), streak.sample(false, 6 * hour));
|
||||
time.set(10 * hour * 1_000_000); // Time outside qualifying darkness earns nothing.
|
||||
assertEquals(new DarknessStreak.Progress(0, false), streak.sample(true, 6 * hour));
|
||||
time.set(16 * hour * 1_000_000);
|
||||
assertEquals(new DarknessStreak.Progress(6 * hour, true), streak.sample(true, 6 * hour));
|
||||
assertEquals(new DarknessStreak.Progress(6 * hour, true), streak.interrupt());
|
||||
assertTrue(streak.sample(false, 12 * hour).complete(), "Higher thresholds do not revoke completion");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
class EchoRewardFailureTest {
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"online", "offline", "disabled", "scheduler-rejected"})
|
||||
void failedEarnedRewardReportsRecoveryEvenWithoutPlayerCallback(String mode) throws Exception {
|
||||
var entered = new CountDownLatch(1);
|
||||
var release = new CountDownLatch(1);
|
||||
var repository = new StateRepository() {
|
||||
@Override public IgnisState load() { return IgnisState.empty(); }
|
||||
@Override public void save(IgnisState state) throws IOException {
|
||||
entered.countDown();
|
||||
try { if (!release.await(5, TimeUnit.SECONDS)) { throw new IOException("test barrier timeout"); } }
|
||||
catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new IOException(failure); }
|
||||
throw new IOException("simulated unavailable storage");
|
||||
}
|
||||
};
|
||||
var enabled = new AtomicBoolean(true);
|
||||
var plugin = mock(JavaPlugin.class);
|
||||
when(plugin.isEnabled()).thenAnswer(call -> enabled.get());
|
||||
var server = mock(Server.class);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
var logger = mock(java.util.logging.Logger.class);
|
||||
when(plugin.getLogger()).thenReturn(logger);
|
||||
var player = mock(Player.class);
|
||||
UUID owner = UUID.randomUUID();
|
||||
when(player.getUniqueId()).thenReturn(owner);
|
||||
if (mode.equals("online")) { when(server.getPlayer(owner)).thenReturn(player); }
|
||||
var callbacks = new java.util.concurrent.LinkedBlockingQueue<Runnable>();
|
||||
var scheduler = mock(org.bukkit.scheduler.BukkitScheduler.class);
|
||||
when(server.getScheduler()).thenReturn(scheduler);
|
||||
when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(call -> {
|
||||
if (mode.equals("scheduler-rejected")) { throw new org.bukkit.plugin.IllegalPluginAccessException("simulated shutdown"); }
|
||||
callbacks.add(call.getArgument(1));
|
||||
return null;
|
||||
});
|
||||
var world = mock(World.class);
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
var block = mock(Block.class);
|
||||
when(block.getType()).thenReturn(Material.END_STONE);
|
||||
when(world.getBlockAt(any(Location.class))).thenReturn(block);
|
||||
try (var states = StateService.open(repository).get(5, TimeUnit.SECONDS)) {
|
||||
var gameplay = new EchoGameplay(plugin, states, new EchoItems());
|
||||
var damage = mock(EntityDamageEvent.class);
|
||||
when(damage.getEntity()).thenReturn(player);
|
||||
when(damage.getCause()).thenReturn(EntityDamageEvent.DamageCause.VOID);
|
||||
when(damage.getFinalDamage()).thenReturn(4.0);
|
||||
gameplay.onDamage(damage);
|
||||
var move = mock(PlayerMoveEvent.class);
|
||||
when(move.getPlayer()).thenReturn(player);
|
||||
when(move.hasChangedPosition()).thenReturn(true);
|
||||
when(move.getTo()).thenReturn(new Location(world, 0, 10, 0));
|
||||
when(player.isGliding()).thenReturn(true);
|
||||
gameplay.onMove(move);
|
||||
when(player.isOnGround()).thenReturn(true);
|
||||
gameplay.onMove(move);
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
enabled.set(!mode.equals("disabled"));
|
||||
release.countDown();
|
||||
states.update(state -> state).handle((state, failure) -> null).get(5, TimeUnit.SECONDS);
|
||||
verify(logger).warning(contains("administrator recovery"));
|
||||
verify(logger).warning(contains(owner.toString()));
|
||||
assertFalse(states.snapshot().players().containsKey(owner));
|
||||
verify(server, never()).getPlayer(any(UUID.class));
|
||||
if (mode.equals("online") || mode.equals("offline")) {
|
||||
var callback = callbacks.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(callback);
|
||||
callback.run();
|
||||
} else { assertTrue(callbacks.isEmpty()); }
|
||||
if (mode.equals("online")) { verify(player).sendMessage(contains("contact an administrator")); }
|
||||
else { verify(player, never()).sendMessage(anyString()); }
|
||||
} finally { release.countDown(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HeartConditionsTest {
|
||||
@Test
|
||||
void darknessWorksEverywhereButNightOnlyWorksInNormalCycleWorlds() {
|
||||
assertTrue(HeartConditions.active(true, true, false));
|
||||
assertTrue(HeartConditions.active(true, false, false));
|
||||
assertTrue(HeartConditions.active(true, false, true));
|
||||
assertTrue(HeartConditions.active(false, true, true));
|
||||
assertFalse(HeartConditions.active(false, true, false));
|
||||
assertFalse(HeartConditions.active(false, false, true));
|
||||
assertFalse(HeartConditions.active(false, false, false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.inventory.CraftItemEvent;
|
||||
import org.bukkit.inventory.CraftingInventory;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.RecipeChoice;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class HeartCraftingTest {
|
||||
@TempDir Path directory;
|
||||
@Test
|
||||
void ingredientValidationCannotBeBypassedWithAnAuthenticatedPreview() throws Exception {
|
||||
UUID owner = UUID.randomUUID(), receipt = UUID.randomUUID();
|
||||
try (var states = StateService.open(new FileStateRepository(directory.resolve("state.bin"))).get()) {
|
||||
states.update(state -> new TradableRewards(Track.HEART).issue(state, owner, receipt, true)).get();
|
||||
var items = mock(HeartItems.class);
|
||||
var product = mock(ItemStack.class);
|
||||
when(items.identify(product)).thenReturn(java.util.Optional.of(new ItemToken(owner, receipt, 0)));
|
||||
var player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(owner);
|
||||
var recipe = mock(ShapedRecipe.class);
|
||||
when(recipe.getKey()).thenReturn(HeartCrafting.RECIPE);
|
||||
var inventory = mock(CraftingInventory.class);
|
||||
when(inventory.getResult()).thenReturn(product);
|
||||
when(inventory.getMatrix()).thenReturn(new ItemStack[9]);
|
||||
var event = mock(CraftItemEvent.class);
|
||||
when(event.getInventory()).thenReturn(inventory);
|
||||
when(event.getRecipe()).thenReturn(recipe);
|
||||
when(event.getWhoClicked()).thenReturn(player);
|
||||
new HeartCrafting(null, states, items, null).onCraft(event);
|
||||
verify(event).setCancelled(true);
|
||||
verify(inventory).setResult(null);
|
||||
verify(inventory, never()).setMatrix(any());
|
||||
}
|
||||
}
|
||||
@Test
|
||||
void recipeUsesOrthogonalBottlesAndCornerHealingWithCentralHeart() {
|
||||
var recipe = mock(ShapedRecipe.class);
|
||||
var bottle = mock(RecipeChoice.ExactChoice.class);
|
||||
var healing = mock(RecipeChoice.ExactChoice.class);
|
||||
HeartCrafting.configureRecipe(recipe, bottle, healing);
|
||||
verify(recipe).shape("PBP", "BCB", "PBP");
|
||||
verify(recipe).setIngredient('B', bottle);
|
||||
verify(recipe).setIngredient('P', healing);
|
||||
verify(recipe).setIngredient('C', Material.CREAKING_HEART);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HeartEffectsTest {
|
||||
@Test
|
||||
void ticksEachPlayerOnceAndWithdrawsBeforeDiscardingSessionOwnership() {
|
||||
var plugin = mock(JavaPlugin.class);
|
||||
var server = mock(Server.class);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
var player = mock(Player.class);
|
||||
var id = UUID.randomUUID();
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
doReturn(java.util.List.of(player)).when(server).getOnlinePlayers();
|
||||
var regeneration = mock(HeartRegeneration.class);
|
||||
var channel = mock(HeartRegeneration.Channel.class);
|
||||
var effects = new HeartEffects(plugin, regeneration, ignored -> true, ignored -> channel);
|
||||
effects.run();
|
||||
verify(regeneration, times(1)).tick(id, true, channel);
|
||||
effects.close();
|
||||
var ordered = inOrder(regeneration);
|
||||
ordered.verify(regeneration).tick(id, true, channel);
|
||||
ordered.verify(regeneration).tick(id, false, channel);
|
||||
ordered.verify(regeneration).forget(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HeartEligibilityTest {
|
||||
@Test
|
||||
void inventoryPresenceNeedsPersonalUnlockValidIssuerAndDarknessOrNormalNight() {
|
||||
UUID issuer = UUID.randomUUID(), holder = UUID.randomUUID(), receipt = UUID.randomUUID();
|
||||
var token = new ItemToken(issuer, receipt, 0);
|
||||
var state = new TradableRewards(Track.HEART).issue(IgnisState.empty(), issuer, receipt, true);
|
||||
assertFalse(HeartEligibility.eligible(state, holder, List.of(token), true, true, true));
|
||||
state = ProgressAdministration.progress(state, holder, Track.HEART, state.limits().darknessMillis(), false);
|
||||
assertTrue(HeartEligibility.eligible(state, holder, List.of(token, token), false, true, true));
|
||||
assertFalse(HeartEligibility.eligible(state, holder, List.of(token), false, false, true));
|
||||
assertTrue(HeartEligibility.eligible(state, holder, List.of(token), true, false, false));
|
||||
assertFalse(HeartEligibility.eligible(state, holder, List.of(), true, true, true));
|
||||
state = ProgressAdministration.reset(state, issuer, Set.of(Track.HEART));
|
||||
assertFalse(HeartEligibility.eligible(state, holder, List.of(token), true, true, true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HeartEnvironmentTest {
|
||||
@Test
|
||||
void totalLightMustBeZeroAndUnloadedChunksAreNotQueried() {
|
||||
var world = mock(World.class);
|
||||
var block = mock(Block.class);
|
||||
var location = new Location(world, -1, 64, 32);
|
||||
assertFalse(HeartEnvironment.dark(location));
|
||||
verify(world, never()).getBlockAt(anyInt(), anyInt(), anyInt());
|
||||
when(world.isChunkLoaded(-1, 2)).thenReturn(true);
|
||||
when(world.getBlockAt(-1, 64, 32)).thenReturn(block);
|
||||
when(block.getLightLevel()).thenReturn((byte) 0);
|
||||
assertTrue(HeartEnvironment.dark(location));
|
||||
when(block.getLightLevel()).thenReturn((byte) 1);
|
||||
assertFalse(HeartEnvironment.dark(location));
|
||||
assertFalse(HeartEnvironment.dark(null));
|
||||
assertFalse(HeartEnvironment.dark(new Location(null, 0, 0, 0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.OminousBottleMeta;
|
||||
import org.bukkit.inventory.meta.PotionMeta;
|
||||
import org.bukkit.potion.PotionType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HeartIngredientsTest {
|
||||
@Test
|
||||
void validatesAllNineRecipePositionsRatherThanOnlyMaterialNames() {
|
||||
var bottle = mock(ItemStack.class);
|
||||
var omen = mock(OminousBottleMeta.class);
|
||||
when(bottle.getType()).thenReturn(Material.OMINOUS_BOTTLE);
|
||||
when(bottle.getItemMeta()).thenReturn(omen);
|
||||
when(omen.hasAmplifier()).thenReturn(true);
|
||||
when(omen.getAmplifier()).thenReturn(4);
|
||||
var potion = mock(ItemStack.class);
|
||||
var meta = mock(PotionMeta.class);
|
||||
when(potion.getType()).thenReturn(Material.LINGERING_POTION);
|
||||
when(potion.getItemMeta()).thenReturn(meta);
|
||||
when(meta.getBasePotionType()).thenReturn(PotionType.STRONG_HEALING);
|
||||
var center = mock(ItemStack.class);
|
||||
when(center.getType()).thenReturn(Material.CREAKING_HEART);
|
||||
ItemStack[] grid = {potion, bottle, potion, bottle, center, bottle, potion, bottle, potion};
|
||||
assertTrue(HeartIngredients.matrix(grid));
|
||||
grid[0] = bottle;
|
||||
assertFalse(HeartIngredients.matrix(grid));
|
||||
grid[0] = potion;
|
||||
grid[4] = potion;
|
||||
assertFalse(HeartIngredients.matrix(grid));
|
||||
assertFalse(HeartIngredients.matrix(new ItemStack[4]));
|
||||
assertFalse(HeartIngredients.matrix(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiresBottleFiveAndLingeringInstantHealthTwoWithoutAddedEffects() {
|
||||
var bottle = mock(ItemStack.class);
|
||||
var omen = mock(OminousBottleMeta.class);
|
||||
when(bottle.getType()).thenReturn(Material.OMINOUS_BOTTLE);
|
||||
when(bottle.getItemMeta()).thenReturn(omen);
|
||||
when(omen.hasAmplifier()).thenReturn(true);
|
||||
when(omen.getAmplifier()).thenReturn(4); // Zero-based amplifier: level V.
|
||||
assertTrue(HeartIngredients.bottle(bottle));
|
||||
when(omen.getAmplifier()).thenReturn(3);
|
||||
assertFalse(HeartIngredients.bottle(bottle));
|
||||
var potion = mock(ItemStack.class);
|
||||
var meta = mock(PotionMeta.class);
|
||||
when(potion.getType()).thenReturn(Material.LINGERING_POTION);
|
||||
when(potion.getItemMeta()).thenReturn(meta);
|
||||
when(meta.getBasePotionType()).thenReturn(PotionType.STRONG_HEALING);
|
||||
assertTrue(HeartIngredients.healing(potion));
|
||||
when(meta.hasCustomEffects()).thenReturn(true);
|
||||
assertFalse(HeartIngredients.healing(potion));
|
||||
when(meta.hasCustomEffects()).thenReturn(false);
|
||||
when(meta.getBasePotionType()).thenReturn(PotionType.HEALING);
|
||||
assertFalse(HeartIngredients.healing(potion));
|
||||
when(meta.getBasePotionType()).thenReturn(PotionType.STRONG_HEALING);
|
||||
when(potion.getType()).thenReturn(Material.SPLASH_POTION);
|
||||
assertFalse(HeartIngredients.healing(potion));
|
||||
assertFalse(HeartIngredients.bottle(null));
|
||||
assertFalse(HeartIngredients.healing(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HeartInitializationTest {
|
||||
@Test
|
||||
void initializationAcknowledgesOnlyDurablyClearedUnfinishedProgress() throws Exception {
|
||||
var id = UUID.randomUUID();
|
||||
class Repository implements StateRepository {
|
||||
IgnisState saved = ProgressAdministration.progress(IgnisState.empty(), id, Track.HEART, 123, false);
|
||||
@Override public IgnisState load() { return saved; }
|
||||
@Override public void save(IgnisState state) { saved = state; }
|
||||
}
|
||||
var repository = new Repository();
|
||||
try (var service = StateService.open(repository).get()) {
|
||||
assertSame(service, HeartProgressState.prepare(service).get());
|
||||
assertEquals(0, service.snapshot().players().get(id).track(Track.HEART).progress());
|
||||
assertEquals(0, repository.saved.players().get(id).track(Track.HEART).progress());
|
||||
}
|
||||
}
|
||||
@Test
|
||||
void failedNormalizationKeepsOriginalStateAndClosesTheWorker() throws Exception {
|
||||
var original = ProgressAdministration.progress(IgnisState.empty(), UUID.randomUUID(), Track.HEART, 123, false);
|
||||
var repository = new StateRepository() {
|
||||
@Override public IgnisState load() { return original; }
|
||||
@Override public void save(IgnisState state) throws IOException { throw new IOException("fixture storage failure"); }
|
||||
};
|
||||
try (var service = StateService.open(repository).get()) {
|
||||
assertThrows(CompletionException.class, () -> HeartProgressState.prepare(service).join());
|
||||
assertEquals(original, service.snapshot());
|
||||
var failure = assertThrows(CompletionException.class, () -> service.update(state -> state).join());
|
||||
assertInstanceOf(RejectedExecutionException.class, failure.getCause());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
import org.bukkit.persistence.PersistentDataContainer;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HeartItemsTest {
|
||||
@Test
|
||||
void onlyHeartMaterialAndItsOwnReceiptNamespaceAuthenticate() {
|
||||
var item = mock(ItemStack.class);
|
||||
var meta = mock(ItemMeta.class);
|
||||
var tags = mock(PersistentDataContainer.class);
|
||||
when(item.getType()).thenReturn(Material.CREAKING_HEART);
|
||||
when(item.hasItemMeta()).thenReturn(true);
|
||||
when(item.getItemMeta()).thenReturn(meta);
|
||||
when(meta.getPersistentDataContainer()).thenReturn(tags);
|
||||
var items = new HeartItems();
|
||||
assertTrue(items.identify(item).isEmpty());
|
||||
var token = new ItemToken(UUID.randomUUID(), UUID.randomUUID(), 2);
|
||||
when(tags.get(new NamespacedKey("spigotignis", "heart_identity"), PersistentDataType.STRING))
|
||||
.thenReturn(EchoIdentity.encode(new EchoToken(token.issuer(), token.receipt(), token.generation())));
|
||||
assertEquals(token, items.identify(item).orElseThrow());
|
||||
assertTrue(new DaggerItems().identify(item).isEmpty());
|
||||
when(item.getType()).thenReturn(Material.OMINOUS_TRIAL_KEY);
|
||||
assertTrue(items.identify(item).isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.event.block.BlockPlaceEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HeartPlacementTest {
|
||||
@Test
|
||||
void issuedCopiesCannotBecomeBlocksEvenWhenTheirReceiptIsRevoked() {
|
||||
var items = mock(HeartItems.class);
|
||||
var item = mock(ItemStack.class);
|
||||
var event = mock(BlockPlaceEvent.class);
|
||||
when(event.getItemInHand()).thenReturn(item);
|
||||
when(items.identify(item)).thenReturn(Optional.of(new ItemToken(UUID.randomUUID(), UUID.randomUUID(), 0)));
|
||||
new HeartPlacement(items).onPlace(event);
|
||||
verify(event).setCancelled(true);
|
||||
}
|
||||
@Test
|
||||
void ordinaryCreakingHeartsRemainPlaceable() {
|
||||
var items = mock(HeartItems.class);
|
||||
var item = mock(ItemStack.class);
|
||||
var event = mock(BlockPlaceEvent.class);
|
||||
when(event.getItemInHand()).thenReturn(item);
|
||||
when(items.identify(item)).thenReturn(Optional.empty());
|
||||
new HeartPlacement(items).onPlace(event);
|
||||
verify(event, never()).setCancelled(anyBoolean());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HeartProgressListenerTest {
|
||||
@Test
|
||||
void observesOnlinePlayersAndInterruptsOnlyUncancelledPositiveDamage() {
|
||||
var plugin = mock(JavaPlugin.class);
|
||||
var server = mock(Server.class);
|
||||
var player = mock(Player.class);
|
||||
var id = UUID.randomUUID();
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
doReturn(java.util.List.of(player)).when(server).getOnlinePlayers();
|
||||
var progress = mock(DarknessProgress.class);
|
||||
when(progress.checkpoint(id)).thenReturn(CompletableFuture.completedFuture(IgnisState.empty()));
|
||||
var listener = new HeartProgressListener(plugin, progress, ignored -> true, error -> { throw new AssertionError(error); });
|
||||
listener.run();
|
||||
verify(progress).observe(id, true);
|
||||
var damage = mock(EntityDamageEvent.class);
|
||||
when(damage.getEntity()).thenReturn(player);
|
||||
when(damage.getFinalDamage()).thenReturn(1.0);
|
||||
when(damage.isCancelled()).thenReturn(true);
|
||||
listener.onDamage(damage);
|
||||
verify(progress, never()).interrupt(id);
|
||||
when(damage.isCancelled()).thenReturn(false);
|
||||
when(damage.getFinalDamage()).thenReturn(0.0);
|
||||
listener.onDamage(damage);
|
||||
verify(progress, never()).interrupt(id);
|
||||
when(damage.getFinalDamage()).thenReturn(1.0);
|
||||
listener.onDamage(damage);
|
||||
verify(progress).interrupt(id);
|
||||
verify(progress).checkpoint(id);
|
||||
}
|
||||
@Test
|
||||
void enteringLightAndDisconnectingInterruptButCancelledMovementDoesNot() {
|
||||
var player = mock(Player.class);
|
||||
var id = UUID.randomUUID();
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
var progress = mock(DarknessProgress.class);
|
||||
when(progress.checkpoint(id)).thenReturn(CompletableFuture.completedFuture(IgnisState.empty()));
|
||||
var listener = new HeartProgressListener(null, progress, ignored -> false, error -> { throw new AssertionError(error); });
|
||||
var move = mock(PlayerMoveEvent.class);
|
||||
when(move.getPlayer()).thenReturn(player);
|
||||
when(move.getTo()).thenReturn(new Location(null, 0, 0, 0));
|
||||
when(move.isCancelled()).thenReturn(true);
|
||||
listener.onMove(move);
|
||||
verifyNoInteractions(progress);
|
||||
when(move.isCancelled()).thenReturn(false);
|
||||
listener.onMove(move);
|
||||
verify(progress).interrupt(id);
|
||||
var quit = mock(PlayerQuitEvent.class);
|
||||
when(quit.getPlayer()).thenReturn(player);
|
||||
listener.onQuit(quit);
|
||||
verify(progress, times(2)).interrupt(id);
|
||||
verify(progress).checkpoint(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HeartProgressStateTest {
|
||||
@Test
|
||||
void restartClearsOnlyUnfinishedDarknessWithoutRevokingCompletedRewardsOrOtherTracks() {
|
||||
UUID unfinished = UUID.randomUUID(), finished = UUID.randomUUID(), receipt = UUID.randomUUID();
|
||||
var state = ProgressAdministration.progress(IgnisState.empty(), unfinished, Track.HEART, 1000, false);
|
||||
state = ProgressAdministration.progress(state, unfinished, Track.DAGGER, 11, false);
|
||||
state = new TradableRewards(Track.HEART).issue(state, finished, receipt, true);
|
||||
var restarted = HeartProgressState.restart(state);
|
||||
assertEquals(0, restarted.players().get(unfinished).track(Track.HEART).progress());
|
||||
assertEquals(11, restarted.players().get(unfinished).track(Track.DAGGER).progress());
|
||||
assertEquals(state.players().get(finished), restarted.players().get(finished));
|
||||
assertEquals(1000, state.players().get(unfinished).track(Track.HEART).progress());
|
||||
assertEquals(state.limits(), restarted.limits());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HeartRegenerationTest {
|
||||
@Test
|
||||
void shutdownRestoresWeakerEffectsEvenWhenDisabledListenersReceiveNoEvents() {
|
||||
var controller = new HeartRegeneration(() -> 0L);
|
||||
var id = UUID.randomUUID();
|
||||
class Channel implements HeartRegeneration.Channel {
|
||||
HeartRegeneration.Effect effect = new HeartRegeneration.Effect(0, 200, false, true, true);
|
||||
boolean events = true;
|
||||
@Override public HeartRegeneration.Effect current() { return effect; }
|
||||
@Override public void apply(HeartRegeneration.Effect next) {
|
||||
effect = next;
|
||||
if (events) controller.changed(id, true, true);
|
||||
}
|
||||
@Override public void remove() { effect = null; }
|
||||
}
|
||||
var channel = new Channel();
|
||||
controller.tick(id, true, channel);
|
||||
channel.events = false;
|
||||
controller.tick(id, false, channel);
|
||||
assertEquals(new HeartRegeneration.Effect(0, 200, false, true, true), channel.effect);
|
||||
}
|
||||
|
||||
@Test
|
||||
void endingSessionDropsOwnershipRatherThanRestoringAnOfflineAgedBackupLater() {
|
||||
var controller = new HeartRegeneration(() -> 0L);
|
||||
var id = UUID.randomUUID();
|
||||
class Channel implements HeartRegeneration.Channel {
|
||||
HeartRegeneration.Effect effect;
|
||||
int removals;
|
||||
@Override public HeartRegeneration.Effect current() { return effect; }
|
||||
@Override public void apply(HeartRegeneration.Effect next) { effect = next; controller.changed(id, true, true); }
|
||||
@Override public void remove() { removals++; } // External plugin vetoes removal.
|
||||
}
|
||||
var channel = new Channel();
|
||||
controller.tick(id, true, channel);
|
||||
controller.tick(id, false, channel);
|
||||
controller.forget(id);
|
||||
controller.tick(id, false, channel);
|
||||
assertEquals(1, channel.removals);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancellationAndStrongerExternalEffectsTakePrecedenceAndInfiniteBackupStaysInfinite() {
|
||||
var controller = new HeartRegeneration(() -> 1000L);
|
||||
var id = UUID.randomUUID();
|
||||
class Channel implements HeartRegeneration.Channel {
|
||||
HeartRegeneration.Effect effect = new HeartRegeneration.Effect(0, -1, false, true, true);
|
||||
boolean allowApply, allowRemove;
|
||||
int removals;
|
||||
@Override public HeartRegeneration.Effect current() { return effect; }
|
||||
@Override public void apply(HeartRegeneration.Effect next) {
|
||||
if (allowApply) { effect = next; controller.changed(id, true, true); }
|
||||
}
|
||||
@Override public void remove() {
|
||||
if (allowRemove) { removals++; effect = null; controller.changed(id, true, false); }
|
||||
}
|
||||
}
|
||||
var channel = new Channel();
|
||||
controller.tick(id, true, channel);
|
||||
controller.tick(id, false, channel);
|
||||
assertEquals(0, channel.effect.amplifier());
|
||||
channel.allowApply = true;
|
||||
controller.tick(id, true, channel);
|
||||
controller.tick(id, false, channel);
|
||||
assertEquals(1, channel.effect.amplifier(), "Cancelled removal is respected");
|
||||
channel.allowRemove = true;
|
||||
controller.tick(id, false, channel);
|
||||
assertEquals(-1, channel.effect.ticks());
|
||||
channel.effect = new HeartRegeneration.Effect(2, 400, false, true, true);
|
||||
controller.changed(id, true, true);
|
||||
controller.tick(id, true, channel);
|
||||
controller.tick(id, false, channel);
|
||||
assertEquals(2, channel.effect.amplifier());
|
||||
assertEquals(1, channel.removals);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anExternalClearCannotResurrectTheWeakerEffectOnLaterUnequip() {
|
||||
var controller = new HeartRegeneration(() -> 0L);
|
||||
var id = UUID.randomUUID();
|
||||
class Channel implements HeartRegeneration.Channel {
|
||||
HeartRegeneration.Effect effect = new HeartRegeneration.Effect(0, 200, false, true, true);
|
||||
@Override public HeartRegeneration.Effect current() { return effect; }
|
||||
@Override public void apply(HeartRegeneration.Effect next) { effect = next; controller.changed(id, true, true); }
|
||||
@Override public void remove() { effect = null; controller.changed(id, true, false); }
|
||||
}
|
||||
var channel = new Channel();
|
||||
controller.tick(id, true, channel);
|
||||
channel.effect = null;
|
||||
controller.changed(id, true, false); // Milk or another plugin cleared regeneration.
|
||||
controller.tick(id, true, channel);
|
||||
controller.tick(id, false, channel);
|
||||
assertNull(channel.effect);
|
||||
}
|
||||
|
||||
@Test
|
||||
void overlaysRegenerationTwoAndRestoresOnlyTheRemainingWeakerEffect() {
|
||||
var clock = new AtomicLong();
|
||||
var controller = new HeartRegeneration(clock::get);
|
||||
var id = UUID.randomUUID();
|
||||
class Channel implements HeartRegeneration.Channel {
|
||||
HeartRegeneration.Effect effect = new HeartRegeneration.Effect(0, 200, false, true, true);
|
||||
@Override public HeartRegeneration.Effect current() { return effect; }
|
||||
@Override public void apply(HeartRegeneration.Effect next) { effect = next; controller.changed(id, true, true); }
|
||||
@Override public void remove() { effect = null; controller.changed(id, true, false); }
|
||||
}
|
||||
var channel = new Channel();
|
||||
controller.tick(id, true, channel);
|
||||
assertEquals(1, channel.effect.amplifier());
|
||||
assertEquals(40, channel.effect.ticks());
|
||||
for (int tick = 10; tick <= 100; tick += 10) {
|
||||
clock.set(tick);
|
||||
var old = channel.effect;
|
||||
channel.effect = new HeartRegeneration.Effect(old.amplifier(), old.ticks() - 10, old.ambient(), old.particles(), old.icon());
|
||||
controller.tick(id, true, channel);
|
||||
}
|
||||
controller.tick(id, false, channel);
|
||||
assertEquals(new HeartRegeneration.Effect(0, 100, false, true, true), channel.effect);
|
||||
controller.tick(id, false, channel);
|
||||
assertEquals(0, channel.effect.amplifier(), "Restored external regeneration is not claimed as ours");
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,35 @@ import org.junit.jupiter.api.Test;
|
||||
class IgnisCommandsTest {
|
||||
@org.junit.jupiter.api.io.TempDir java.nio.file.Path directory;
|
||||
|
||||
@Test
|
||||
void administrativeHeartSetUsesTheTrackerRebaseBarrier() throws Exception {
|
||||
var plugin = mock(JavaPlugin.class);
|
||||
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
|
||||
var sender = mock(CommandSender.class);
|
||||
when(sender.hasPermission("ignis.admin")).thenReturn(true);
|
||||
var saved = new java.util.concurrent.CountDownLatch(1);
|
||||
doAnswer(call -> { saved.countDown(); return null; }).when(sender).sendMessage("Ignis change saved.");
|
||||
var command = mock(Command.class);
|
||||
when(command.getName()).thenReturn("ignisadmin");
|
||||
var dispatch = mock(EchoGameplay.class);
|
||||
doAnswer(call -> { ((Runnable) call.getArgument(0)).run(); return null; }).when(dispatch).onServer(any());
|
||||
var owner = java.util.UUID.randomUUID();
|
||||
try (var states = StateService.open(new FileStateRepository(directory.resolve("heart.bin"))).get()) {
|
||||
states.update(state -> ProgressAdministration.progress(state, owner, Track.HEART, 0, false)).get();
|
||||
var clock = new java.util.concurrent.atomic.AtomicLong();
|
||||
var tracker = new DarknessProgress(states, clock::get);
|
||||
tracker.observe(owner, true);
|
||||
clock.set(100_000_000L);
|
||||
tracker.observe(owner, true);
|
||||
var commands = new IgnisCommands(plugin, states, dispatch, null, null, tracker);
|
||||
commands.onCommand(sender, command, "ignisadmin", new String[]{"progress", "set", owner.toString(), "heart", "2"});
|
||||
assertTrue(saved.await(3, java.util.concurrent.TimeUnit.SECONDS));
|
||||
tracker.observe(owner, true);
|
||||
tracker.checkpoint(owner).get();
|
||||
assertEquals(2, states.snapshot().players().get(owner).track(Track.HEART).progress());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void administrativePearlSetFlushesOlderCreditsBeforeOverwritingCounter() throws Exception {
|
||||
var plugin = mock(JavaPlugin.class);
|
||||
@@ -58,7 +87,7 @@ class IgnisCommandsTest {
|
||||
assertTrue(states.snapshot().players().get(owner).track(Track.MASK).unlocked());
|
||||
assertEquals(100, states.snapshot().limits().invisibilityMillis());
|
||||
assertTrue(handler.onTabComplete(sender, command, "ignisadmin", new String[]{""}).contains("inspect"));
|
||||
assertEquals(java.util.List.of("dagger", "echo", "mask"), handler.onTabComplete(sender, command, "ignisadmin", new String[]{"give", owner.toString(), ""}));
|
||||
assertEquals(java.util.List.of("dagger", "echo", "heart", "mask"), handler.onTabComplete(sender, command, "ignisadmin", new String[]{"give", owner.toString(), ""}));
|
||||
handler.onCommand(sender, command, "ignisadmin", new String[]{"give", owner.toString(), "mask"});
|
||||
states.update(state -> state).get();
|
||||
assertTrue(states.snapshot().players().get(owner).pendingRewards().containsValue(Track.MASK));
|
||||
@@ -66,6 +95,10 @@ class IgnisCommandsTest {
|
||||
states.update(state -> state).get();
|
||||
assertTrue(states.snapshot().players().get(owner).pendingRewards().containsValue(Track.DAGGER));
|
||||
assertTrue(states.snapshot().players().get(owner).track(Track.DAGGER).unlocked());
|
||||
handler.onCommand(sender, command, "ignisadmin", new String[]{"give", owner.toString(), "heart"});
|
||||
states.update(state -> state).get();
|
||||
assertTrue(states.snapshot().players().get(owner).pendingRewards().containsValue(Track.HEART));
|
||||
assertTrue(states.snapshot().players().get(owner).track(Track.HEART).unlocked());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ProgressInspectionTest {
|
||||
@TempDir Path directory;
|
||||
private final UUID owner = UUID.randomUUID(), worldId = UUID.randomUUID();
|
||||
private final JavaPlugin plugin = mock(JavaPlugin.class);
|
||||
private final Server server = mock(Server.class);
|
||||
private final Player player = mock(Player.class);
|
||||
private final World world = mock(World.class);
|
||||
private final CommandSender admin = mock(CommandSender.class);
|
||||
private final Command command = mock(Command.class);
|
||||
private final EchoItems items = mock(EchoItems.class);
|
||||
private final List<String> messages = new ArrayList<>();
|
||||
private StateService states;
|
||||
private EchoGameplay gameplay;
|
||||
private IgnisCommands handler;
|
||||
private Path file;
|
||||
|
||||
@BeforeEach
|
||||
void setup() throws Exception {
|
||||
file = directory.resolve("state.bin");
|
||||
states = StateService.open(new FileStateRepository(file)).get();
|
||||
states.update(state -> new IgnisState(state.limits(), Map.of(owner, PlayerState.empty()))).get();
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(player.getUniqueId()).thenReturn(owner);
|
||||
when(player.getName()).thenReturn("Target");
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(player.getY()).thenReturn(-65.0);
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
when(server.getPlayerExact("Target")).thenReturn(player);
|
||||
when(admin.hasPermission("ignis.admin")).thenReturn(true);
|
||||
when(command.getName()).thenReturn("ignisadmin");
|
||||
doAnswer(call -> { messages.add(call.getArgument(0, String.class)); return null; })
|
||||
.when(admin).sendMessage(anyString());
|
||||
gameplay = new EchoGameplay(plugin, states, items);
|
||||
handler = new IgnisCommands(plugin, states, gameplay);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void close() { if (states != null) states.close(); }
|
||||
|
||||
@Test
|
||||
void commandReportsRealGameplayAttemptStagesWithoutSavingOrInterruptingThem() throws Exception {
|
||||
var before = states.snapshot();
|
||||
byte[] bytes = Files.readAllBytes(file);
|
||||
EntityDamageEvent damage = mock(EntityDamageEvent.class);
|
||||
when(damage.getEntity()).thenReturn(player);
|
||||
when(damage.getCause()).thenReturn(EntityDamageEvent.DamageCause.VOID);
|
||||
when(damage.getFinalDamage()).thenReturn(1.0);
|
||||
gameplay.onDamage(damage);
|
||||
inspect("Target");
|
||||
assertContains("Void challenge: awaiting-elytra-ascent");
|
||||
assertContains("world=" + worldId);
|
||||
assertContains("damage-y=-65.0");
|
||||
when(player.isGliding()).thenReturn(true);
|
||||
gameplay.onMove(new PlayerMoveEvent(player, new Location(world, 0, -65, 0), new Location(world, 0, 20, 0)));
|
||||
messages.clear();
|
||||
inspect("Target");
|
||||
assertContains("Void challenge: awaiting-end-stone");
|
||||
var quit = mock(PlayerQuitEvent.class);
|
||||
when(quit.getPlayer()).thenReturn(player);
|
||||
gameplay.onQuit(quit);
|
||||
messages.clear();
|
||||
inspect(owner.toString());
|
||||
assertContains("Void challenge: none");
|
||||
assertSame(before, states.snapshot());
|
||||
assertArrayEquals(bytes, Files.readAllBytes(file));
|
||||
verifyNoInteractions(items);
|
||||
verify(plugin, never()).getLogger();
|
||||
verify(server, never()).getScheduler();
|
||||
}
|
||||
|
||||
@Test
|
||||
void savedProgressAndRecipeUnlocksDoNotMisreportAnUnconsumedEchoAsActive() throws Exception {
|
||||
UUID receipt = UUID.randomUUID();
|
||||
states.update(state -> EchoRewards.award(ProgressAdministration.progress(
|
||||
ProgressAdministration.progress(state, owner, Track.MASK, state.limits().invisibilityMillis(), false),
|
||||
owner, Track.DAGGER, 3, false), owner, receipt)).get();
|
||||
var before = states.snapshot();
|
||||
byte[] bytes = Files.readAllBytes(file);
|
||||
inspect(owner.toString());
|
||||
assertContains("MASK=" + before.limits().invisibilityMillis());
|
||||
assertContains("DAGGER=3");
|
||||
assertContains("HEART=0");
|
||||
assertContains("Recipe unlocks: mask=unlocked, dagger=locked, heart=locked");
|
||||
assertContains("Echo: eligibility=unlocked; advancement-name scrambling=inactive");
|
||||
assertContains("pending=1");
|
||||
assertContains("issued=1");
|
||||
assertSame(before, states.snapshot());
|
||||
assertArrayEquals(bytes, Files.readAllBytes(file));
|
||||
verify(server, never()).getPlayerExact(anyString());
|
||||
verifyNoInteractions(items);
|
||||
states.update(state -> EchoRewards.activate(state, owner, new EchoToken(owner, receipt, 0))).get();
|
||||
messages.clear();
|
||||
inspect(owner.toString());
|
||||
assertContains("advancement-name scrambling=active");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonAdminInspectionIsDeniedBeforeReadingOrResolvingState() {
|
||||
when(admin.hasPermission("ignis.admin")).thenReturn(false);
|
||||
var denied = new IgnisCommands(plugin, null, null);
|
||||
denied.onCommand(admin, command, "ignisadmin", new String[] {"inspect", "Target"});
|
||||
assertEquals(List.of("You do not have permission to administer Ignis."), messages);
|
||||
verifyNoInteractions(server);
|
||||
}
|
||||
|
||||
private void inspect(String target) {
|
||||
assertTrue(handler.onCommand(admin, command, "ignisadmin", new String[] {"inspect", target}));
|
||||
}
|
||||
|
||||
private void assertContains(String fragment) {
|
||||
assertTrue(messages.stream().anyMatch(message -> message.contains(fragment)),
|
||||
() -> "Missing '" + fragment + "' in " + messages);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
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-ignis-" + 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("SpigotIgnis", metadata.get("name"), "Retain the data directory and persistent namespace");
|
||||
assertEquals("games.dmg.spigotignis.SpigotIgnisPlugin", metadata.get("main"));
|
||||
assertTrue(((Map<?, ?>) metadata.get("permissions")).containsKey("ignis.admin"));
|
||||
assertEquals(java.util.List.of("ProtocolLib"), metadata.get("depend"));
|
||||
var descriptor = new org.bukkit.plugin.PluginDescriptionFile(jar.getInputStream(jar.getJarEntry("plugin.yml")));
|
||||
assertEquals("spigotignis", descriptor.namespace());
|
||||
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/")
|
||||
|| entry.getName().startsWith("org/purpurmc/") || entry.getName().startsWith("com/comphenix/") || 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/spigotignis/SpigotIgnisPlugin.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-ignis-"), workflow);
|
||||
assertFalse(content.contains("spigot-ignis-"), workflow);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,9 @@ class ReleaseCredentialTest {
|
||||
void credentialHelperRefusesOtherHostsProtocolsRepositoriesAndOperations() throws Exception {
|
||||
Path helper = Path.of(".gitea/scripts/git-credential-ignis.sh").toAbsolutePath();
|
||||
for (String request : java.util.List.of(
|
||||
"protocol=https\nhost=evil.example\npath=dmg/spigot-ignis.git\n\n",
|
||||
"protocol=http\nhost=git.garvis.dev\npath=dmg/spigot-ignis.git\n\n",
|
||||
"protocol=https\nhost=evil.example\npath=dmg/purpur-ignis.git\n\n",
|
||||
"protocol=http\nhost=git.garvis.dev\npath=dmg/purpur-ignis.git\n\n",
|
||||
"protocol=https\nhost=git.garvis.dev\npath=dmg/spigot-ignis.git\n\n",
|
||||
"protocol=https\nhost=git.garvis.dev\npath=dmg/other.git\n\n")) {
|
||||
var builder = new ProcessBuilder("sh", helper.toString(), "get");
|
||||
builder.environment().put("IGNIS_RELEASE_TOKEN", "synthetic-test-token");
|
||||
@@ -38,7 +39,7 @@ class ReleaseCredentialTest {
|
||||
builder.environment().put("GIT_TERMINAL_PROMPT", "0");
|
||||
Process process = builder.start();
|
||||
try (var input = process.getOutputStream()) {
|
||||
input.write("protocol=https\nhost=git.garvis.dev\npath=dmg/spigot-ignis.git\n\n".getBytes(StandardCharsets.UTF_8));
|
||||
input.write("protocol=https\nhost=git.garvis.dev\npath=dmg/purpur-ignis.git\n\n".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
assertTrue(process.waitFor(3, TimeUnit.SECONDS));
|
||||
assertEquals(0, process.exitValue());
|
||||
|
||||
@@ -12,7 +12,9 @@ class ReleaseWorkflowTest {
|
||||
Path path = Path.of(".gitea/workflows/release.yml");
|
||||
assertTrue(Files.exists(path), "Release workflow must exist");
|
||||
String workflow = Files.readString(path);
|
||||
assertTrue(workflow.contains("spigot-ignis-${VERSION}.jar"));
|
||||
assertTrue(workflow.contains("purpur-ignis-${VERSION}.jar"));
|
||||
assertEquals(2, workflow.split("test \\\"\\$REPOSITORY\\\" = 'dmg/purpur-ignis'", -1).length - 1);
|
||||
assertFalse(workflow.contains("dmg/spigot-ignis"));
|
||||
assertTrue(workflow.contains("--header @-"));
|
||||
assertTrue(workflow.contains("git-credential-ignis.sh"));
|
||||
assertTrue(workflow.contains("credential.useHttpPath true"));
|
||||
|
||||
@@ -5,6 +5,42 @@ import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class VoidTrialTest {
|
||||
@Test
|
||||
void inspectionIsImmutableAndDoesNotChangeChallengeCompletion() {
|
||||
var trial = new VoidTrial();
|
||||
UUID player = UUID.randomUUID(), world = UUID.randomUUID();
|
||||
assertTrue(trial.snapshot(player).isEmpty());
|
||||
trial.damaged(player, world, -65);
|
||||
assertTrue(trial.snapshot(player).isPresent(), "Active attempt must be inspectable");
|
||||
var initial = trial.snapshot(player).orElseThrow();
|
||||
assertEquals(world, initial.world());
|
||||
assertEquals(-65, initial.damageHeight());
|
||||
assertFalse(initial.escaped());
|
||||
assertEquals(initial, trial.snapshot(player).orElseThrow());
|
||||
trial.moved(player, world, 20, false, false, false);
|
||||
assertFalse(trial.snapshot(player).orElseThrow().escaped());
|
||||
trial.moved(player, world, 20, true, false, false);
|
||||
assertTrue(trial.snapshot(player).orElseThrow().escaped());
|
||||
assertFalse(initial.escaped(), "A returned snapshot must not change after later events");
|
||||
assertTrue(trial.moved(player, world, 60, false, true, true));
|
||||
assertTrue(trial.snapshot(player).isEmpty());
|
||||
assertFalse(trial.moved(player, world, 60, false, true, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void inspectionClearsAfterCancellationWorldChangeAndRestart() {
|
||||
var trial = new VoidTrial();
|
||||
UUID player = UUID.randomUUID(), world = UUID.randomUUID();
|
||||
trial.damaged(player, world, -65);
|
||||
trial.cancel(player);
|
||||
assertTrue(trial.snapshot(player).isEmpty());
|
||||
trial.damaged(player, world, -65);
|
||||
trial.moved(player, UUID.randomUUID(), 60, true, false, false);
|
||||
assertTrue(trial.snapshot(player).isEmpty());
|
||||
trial.damaged(player, world, -65);
|
||||
assertTrue(new VoidTrial().snapshot(player).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiresDamageAscentWhileGlidingAndFirstLandingOnEndStone() {
|
||||
var trial = new VoidTrial();
|
||||
|
||||
Reference in new issue
Block a user