Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b12d4cb5ca | ||
|
|
df6474fd55 | ||
|
|
edada37669 | ||
|
|
608a5fd2aa | ||
|
|
04e224ad95 | ||
|
|
b3473aff18 | ||
|
|
a2140bd8d7 | ||
|
|
7ea6e254eb | ||
|
|
09dd5ded57 | ||
|
|
9896194932 | ||
|
|
167573f953 |
No files matched your search
@@ -1,11 +1,13 @@
|
||||
# purpur-fantasy-stick agent entrypoint
|
||||
|
||||
The canonical stories, engineering guidance, and process documents live in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
|
||||
Canonical engineering guidance and technical/process documents are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
|
||||
|
||||
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-fantasy-stick/index.md`, that project's `engineering.md`, and relevant `../somc-okf/user-stories/purpur-fantasy-stick/` stories. Follow the parent workspace `AGENTS.md` when present. Do not invent defaults for explicitly unresolved gameplay or recovery decisions.
|
||||
Stories, acceptance criteria, status and evidence belong in [Kaneo Social Minecraft](https://cloud.kaneo.app/dmg/SM), with subjects `[purpur-fantasy-stick] Descriptive title` (no `US-###` or `FS-*` prefix). Use the configured authenticated `kaneo` MCP server per [Kaneo access and migration](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md#kaneo-access-and-migration); report unavailable access rather than creating local story copies. OpenProject is read-only history.
|
||||
|
||||
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-fantasy-stick/index.md`, that project's `engineering.md`, and relevant Kaneo tickets with subject prefix `[purpur-fantasy-stick]`. Follow the parent workspace `AGENTS.md` when present. Do not invent defaults for explicitly unresolved gameplay or recovery decisions.
|
||||
|
||||
For standalone checkouts, obtain access to the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-fantasy-stick/index.md) and [development process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Do not create a parallel knowledge bundle. Source builds do not require private wiki access.
|
||||
|
||||
Follow the [development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, meaningful failing tests, minimal passing implementation, immediate criterion evidence, full verification, and source/wiki publication. GitOps preparation is separately approved and committed locally without pushing. Only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed production push.
|
||||
Follow the full [development process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md) and [development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md) for approvals, test-first/full-suite verification, required Gitea PR checks/reviews and merge, and merged-tree verification before ticket completion. Never push source `main` directly. GitOps preparation is separately approved and committed locally without pushing. Only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed production push.
|
||||
|
||||
Use Java 25 and the declared Purpur API. Run `./gradlew clean check jar`. Preserve runtime identity, persistent namespaces, and unrelated player/plugin state. Never expose release credentials in arguments, output, or Git.
|
||||
@@ -7,7 +7,7 @@ Fantasy Stick is the proposed transferable-character RPG plugin for Social Minec
|
||||
## Build and verify
|
||||
|
||||
- Java 25 toolchain (Gradle can resolve it), Gradle wrapper 9.6.1.
|
||||
- Purpur API/runtime `26.2.build.2618-stable`; `plugin.yml` declares API `26.2`.
|
||||
- Purpur API/runtime `26.3.build.2651-stable`; `plugin.yml` declares API `26.3`.
|
||||
- Node.js 20 or later and npm for the TypeScript release-tool tests.
|
||||
- ProtocolLib **5.4.0** is required at runtime and declared as a compile-only dependency; it is not bundled. Install the official [server distribution](https://github.com/dmulloy2/ProtocolLib/releases/tag/5.4.0), not the Maven library JAR (which lacks bundled runtime dependencies).
|
||||
|
||||
@@ -81,7 +81,7 @@ This is **not a transaction commit**. Eleven native planner/composition tests us
|
||||
|
||||
## Fixed-destination craft delivery
|
||||
|
||||
`ManualCraftDelivery` can apply a validated plan and exact-sized saved batch to the cursor, selected hotbar/offhand slot or bulk storage destinations. It rechecks current contents before claiming delivery, constructs all sheets first, then debits fresh native input mirrors and places all outputs before triggering one matrix recomputation. This deliberately relies on the pinned Purpur 2618 mirror/count/setter behavior. No further adapter-planned writes occur after the prepare-callback boundary. Existing native setters may send packets; this is not packet-silent or exception/crash-atomic.
|
||||
`ManualCraftDelivery` can apply a validated plan and exact-sized saved batch to the cursor, selected hotbar/offhand slot or bulk storage destinations. It rechecks current contents before claiming delivery, constructs all sheets first, then debits fresh native input mirrors and places all outputs before triggering one matrix recomputation. This deliberately relies on the pinned Purpur 2651 mirror/count/setter behavior. No further adapter-planned writes occur after the prepare-callback boundary. Existing native setters may send packets; this is not packet-silent or exception/crash-atomic.
|
||||
|
||||
Nine native delivery tests cover normal/right-click, bulk and exact-slot delivery, partial ingredient metadata, empty first matrix cells, stale context refusal, mismatched/abandoned/reused batches, reentrancy, callback item changes, new-menu cursor safety and post-callback exceptions. The fixture uses actual containers/cursor stacks and intercepts `slotsChanged`; it does not execute a listening server's recipe manager or event dispatch. A mismatch refuses before consuming cleanup authority; an exception after claiming delivery conservatively retains records rather than replaying or rolling back.
|
||||
|
||||
@@ -211,7 +211,7 @@ JSONL `effect.applied`/`effect.ended` records use `feature_id=dirt_path_speed` w
|
||||
|
||||
Speed I and Low-Light Vision retain their exact holder/session, native effect-instance identity and displaced-effect evidence while native removal is cancelled or unconfirmed. Bounded current-lifetime sweeps retry after downed containment ends; legitimate rescue preserves the same current lease. Equal-looking foreign instances and stronger/unrelated effects are not cleanup authority. A prior snapshot can be restored only after confirmed owned removal into an empty destination and within its remaining lifetime; fixed native tiers and replacement policies are unchanged.
|
||||
|
||||
A read-only adapter for **pinned Purpur 2618** checks native potion ticking and instance identity; bounded stack inspection refuses reentrant mutation during event/native-effect dispatch. This prevents queued by-type requests from later deleting a replacement and avoids treating mutable, in-flight event flags as completed mutation. Native maps, flags and event cancellation are never overridden. Runtime upgrades require renewed compatibility tests; an unavailable phase/identity is not permission for an unsafe fallback. No potion-event listener is needed, including for partial installation cleanup.
|
||||
A read-only adapter for **pinned Purpur 2651** checks native potion ticking and instance identity; bounded stack inspection refuses reentrant mutation during event/native-effect dispatch. This prevents queued by-type requests from later deleting a replacement and avoids treating mutable, in-flight event flags as completed mutation. Native maps, flags and event cancellation are never overridden. Runtime upgrades require renewed compatibility tests; an unavailable phase/identity is not permission for an unsafe fallback. No potion-event listener is needed, including for partial installation cleanup.
|
||||
|
||||
Owner close cancels its sweep timer and attempts only safe cleanup. Closing during native dispatch can schedule one post-dispatch attempt while the plugin is enabled, not an endless shutdown retry. Cancelled/unreadable/unsafe cleanup remains unresolved in the owner, with no false effect-end acknowledgment; shutdown or permanently denied removal does not guarantee cleanup. There is no cross-restart effect-ownership persistence, automatic recovery command or forced removal policy. Audit availability never authorizes native mutation or discards ownership evidence.
|
||||
|
||||
@@ -219,7 +219,7 @@ Owner close cancels its sweep timer and attempts only safe cleanup. Closing duri
|
||||
|
||||
Offhand entry applies actual native maximum health `min(10 + CON, native health limit)`, resumes saved remaining character health, and supplies the recurring blue active-character aura to native viewers who opt in with `/fstick particles players on`. The account-owned aura preference defaults to off; `/fstick particles players` reports it and `/fstick particles players off` hides only this recurring aura. The independent, default-on `/fstick particles all [on|off]` preference durably controls every explicit Fantasy Stick particle for that account without changing nearby players' delivery, sounds or gameplay. Turning `all` off temporarily takes precedence without overwriting the saved `players` choice. A fresh character's saved health starts full. CON has no gameplay cap; high values do not reject entry. Native damage/healing rules remain in effect, with the existing party/mixed-mode protection. The book reports current native health rather than waiting for an exit save.
|
||||
|
||||
`NativeMaximumHealth` owns only the effective-value cache/dirty callback of that player's existing native maximum-health instance. Base values, modifiers and native potion expiry remain owned by their original sources; nothing is copied back from an old modifier snapshot. The native player health methods and client attribute snapshot are tested. This adapter uses **pinned Purpur 2618 internals**, including private fields; a detached startup probe exercises installation, recalculation and removal before gameplay is registered. Runtime upgrades require renewed native compatibility verification, not a guessed fallback or global attribute-limit changes. Matching native health scaling displays the real fantasy maximum, not a separate virtual health pool; a later unrelated display-scale change is preserved on exit.
|
||||
`NativeMaximumHealth` owns only the effective-value cache/dirty callback of that player's existing native maximum-health instance. Base values, modifiers and native potion expiry remain owned by their original sources; nothing is copied back from an old modifier snapshot. The native player health methods and client attribute snapshot are tested. This adapter uses **pinned Purpur 2651 internals**, including private fields; a detached startup probe exercises installation, recalculation and removal before gameplay is registered. Runtime upgrades require renewed native compatibility verification, not a guessed fallback or global attribute-limit changes. Matching native health scaling displays the real fantasy maximum, not a separate virtual health pool; a later unrelated display-scale change is preserved on exit.
|
||||
|
||||
Ordinary health, food, saturation, exhaustion and health-display state belong to the holder/session, not the character sheet. The session receipt acknowledges this snapshot before native mutation; versioned player PDC records its application/restoration phase. Voluntary exit restores ordinary gear/vitals at the current location, with ordinary health capped by the current legitimate maximum. Character damage is saved on the worker, and the character claim/receipt cannot complete ahead of that acknowledgment. Quit and disable perform the same best-effort cleanup. Failed or uncertain saves retain the receipt and acknowledged character record instead of permitting a stale-health reentry. Missing/ambiguous native proof never authorizes a snapshot heal; process-owned maximum overrides are still retired independently of equipment/client-write failures.
|
||||
|
||||
|
||||
+7
-7
@@ -26,10 +26,10 @@ tasks.withType<JavaCompile>().configureEach {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
|
||||
compileOnly("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable")
|
||||
compileOnly("net.dmulloy2:ProtocolLib:5.4.0")
|
||||
testImplementation("net.dmulloy2:ProtocolLib:5.4.0")
|
||||
testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
|
||||
testImplementation("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable")
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
@@ -77,18 +77,18 @@ tasks.test {
|
||||
// Patch the exact supported runtime for non-listening native API tests, as in Purpur Base.
|
||||
val nativeRuntime = layout.buildDirectory.dir("native-runtime")
|
||||
val downloadNativeRuntime = tasks.register("downloadNativeRuntime") {
|
||||
val launcher = nativeRuntime.map { it.file("purpur-26.2-2618.jar") }
|
||||
val launcher = nativeRuntime.map { it.file("purpur-26.3-2651.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()
|
||||
val connection = URI("https://api.purpurmc.org/v2/purpur/26.3/2651/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"
|
||||
check(digest == "0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598") {
|
||||
"Purpur 2651 checksum mismatch"
|
||||
}
|
||||
file.writeBytes(bytes)
|
||||
}
|
||||
@@ -96,7 +96,7 @@ val downloadNativeRuntime = tasks.register("downloadNativeRuntime") {
|
||||
val prepareNativeRuntime = tasks.register<JavaExec>("prepareNativeRuntime") {
|
||||
dependsOn(downloadNativeRuntime)
|
||||
javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) }
|
||||
classpath = files(nativeRuntime.map { it.file("purpur-26.2-2618.jar") })
|
||||
classpath = files(nativeRuntime.map { it.file("purpur-26.3-2651.jar") })
|
||||
mainClass = "io.papermc.paperclip.Main"
|
||||
jvmArgs("-Dpaperclip.patchonly=true")
|
||||
workingDir(nativeRuntime)
|
||||
|
||||
@@ -255,7 +255,8 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
return;
|
||||
}
|
||||
CharacterRecord saved = mutation.record().orElseThrow();
|
||||
boolean deferNativeCast = audit != null && definition.id().equals("quickstep");
|
||||
boolean deferNativeCast = audit != null
|
||||
&& Set.of("quickstep", "chain_lightning").contains(definition.id());
|
||||
if (castEvents != null && !deferNativeCast) castEvents.cast(audit, saved, readyAt, definition);
|
||||
boolean synchronizedProgress = unchangedDeadline
|
||||
? sessions.currentRecord(input.session()).filter(saved::equals).isPresent()
|
||||
|
||||
@@ -6,6 +6,7 @@ import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.math.MathContext;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
@@ -30,33 +31,36 @@ import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
|
||||
/** Deterministic INT-scaled bounded lightning chain with exact decimal decay. */
|
||||
/** Deterministic bounded lightning chain sharing one exact INT-defined damage pool. */
|
||||
public final class ChainLightning implements AbilityEffect, Listener, AutoCloseable {
|
||||
public record Settings(BigInteger baseDamage, Duration cooldown, double initialRange, double jumpRange,
|
||||
int additionalTargets, BigDecimal jumpMultiplier, BigInteger targetIntStep, int maxTargets) {
|
||||
private static final BigInteger DAMAGE_FACTOR = BigInteger.valueOf(4);
|
||||
private static final MathContext SHARE_PRECISION = MathContext.DECIMAL128;
|
||||
|
||||
public record Settings(Duration cooldown, double initialRange, double jumpRange, int maxTargets) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(jumpMultiplier); Objects.requireNonNull(targetIntStep);
|
||||
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
Objects.requireNonNull(cooldown);
|
||||
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(initialRange) || initialRange <= 0 || initialRange > 64
|
||||
|| !Double.isFinite(jumpRange) || jumpRange <= 0 || jumpRange > 64
|
||||
|| additionalTargets < 0 || additionalTargets > 63 || targetIntStep.signum() <= 0 || maxTargets < 1 || maxTargets > 64
|
||||
|| jumpMultiplier.signum() <= 0 || jumpMultiplier.compareTo(BigDecimal.ONE) > 0) {
|
||||
|| maxTargets < 1 || maxTargets > 20) {
|
||||
throw new IllegalArgumentException("Invalid Chain Lightning settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(BigInteger.valueOf(8), Duration.ofSeconds(18), 16, 6, 4, new BigDecimal("0.75"), BigInteger.valueOf(5), 12);
|
||||
}
|
||||
public static Settings defaults() { return new Settings(Duration.ofSeconds(18), 16, 6, 20); }
|
||||
}
|
||||
|
||||
private record SelectedTarget(LivingEntity target, LivingEntity previous) { }
|
||||
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution; final LivingEntity target; final double requested;
|
||||
final BigDecimal defined; final UUID chainId; final int ordinal;
|
||||
final UUID previousId; final Map<String, Object> previousLocation;
|
||||
final BigDecimal totalDefined; final BigDecimal definedShare; final UUID chainId; final int ordinal;
|
||||
final int selectedChainSize; final UUID previousId; final Map<String, Object> previousLocation;
|
||||
boolean accepted;
|
||||
Attempt(AbilityExecution execution, LivingEntity target, double requested, BigDecimal defined,
|
||||
UUID chainId, int ordinal, LivingEntity previous) {
|
||||
Attempt(AbilityExecution execution, LivingEntity target, double requested, BigDecimal totalDefined,
|
||||
BigDecimal definedShare, UUID chainId, int ordinal, int selectedChainSize, LivingEntity previous) {
|
||||
this.execution = execution; this.target = target; this.requested = requested;
|
||||
this.defined = defined; this.chainId = chainId; this.ordinal = ordinal;
|
||||
this.totalDefined = totalDefined; this.definedShare = definedShare; this.chainId = chainId;
|
||||
this.ordinal = ordinal; this.selectedChainSize = selectedChainSize;
|
||||
previousId = previous == null ? null : previous.getUniqueId();
|
||||
previousLocation = previous == null ? Map.of() : locationData(previous.getLocation());
|
||||
}
|
||||
@@ -82,36 +86,30 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("chain_lightning", "Chain Lightning", Material.LIGHTNING_ROD,
|
||||
List.of("Strike for " + current.baseDamage() + " + INT modifier damage, with "
|
||||
+ Math.min(current.maxTargets(), 1 + current.additionalTargets()) + " base total targets at INT 20.",
|
||||
"Gain one target per " + current.targetIntStep() + " purchased INT above 20, capped at " + current.maxTargets() + " total targets.",
|
||||
"Each jump deals " + decimal(current.jumpMultiplier().movePointRight(2))
|
||||
+ "% of the previous defined damage within " + decimal(BigDecimal.valueOf(current.jumpRange())) + " blocks."),
|
||||
"Use while aiming at the first visible hostile within " + decimal(BigDecimal.valueOf(current.initialRange())) + " blocks.",
|
||||
List.of("Build one damage pool equal to four times your purchased INT modifier.",
|
||||
"Discover up to " + current.maxTargets() + " targets, then divide that pool equally across the complete chain.",
|
||||
"Start within " + decimal(BigDecimal.valueOf(current.initialRange())) + " blocks; each nearest visible jump is within "
|
||||
+ decimal(BigDecimal.valueOf(current.jumpRange())) + " blocks."),
|
||||
"Use while aiming at the first visible hostile; cancelled or invalid shares are not redistributed.",
|
||||
Map.of(Stat.INT, BigInteger.valueOf(20), Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.ATK,
|
||||
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("base-damage", new BigDecimal(current.baseDamage()),
|
||||
"initial-range", BigDecimal.valueOf(current.initialRange()).stripTrailingZeros(),
|
||||
Map.of("initial-range", BigDecimal.valueOf(current.initialRange()).stripTrailingZeros(),
|
||||
"jump-range", BigDecimal.valueOf(current.jumpRange()).stripTrailingZeros(),
|
||||
"additional-targets", BigDecimal.valueOf(current.additionalTargets()),
|
||||
"jump-multiplier", current.jumpMultiplier(),
|
||||
"target-int-step", new BigDecimal(current.targetIntStep()), "max-targets", BigDecimal.valueOf(current.maxTargets())), AbilityDefinition.TargetPolicy.SELECTION, this);
|
||||
"max-targets", BigDecimal.valueOf(current.maxTargets()),
|
||||
"int-modifier-damage-factor", new BigDecimal(DAMAGE_FACTOR)), AbilityDefinition.TargetPolicy.SELECTION, this);
|
||||
}
|
||||
|
||||
public static BigInteger initialDamage(BigInteger intelligence) { return initialDamage(BigInteger.valueOf(8), intelligence); }
|
||||
static BigInteger initialDamage(BigInteger base, BigInteger intelligence) {
|
||||
Objects.requireNonNull(base); Objects.requireNonNull(intelligence);
|
||||
if (base.signum() < 0 || intelligence.signum() < 0) throw new IllegalArgumentException("Invalid Chain Lightning damage input");
|
||||
public static BigInteger intelligenceModifier(BigInteger intelligence) {
|
||||
Objects.requireNonNull(intelligence);
|
||||
if (intelligence.signum() < 0) throw new IllegalArgumentException("Invalid Chain Lightning INT");
|
||||
var delta = intelligence.subtract(BigInteger.TEN); var divided = delta.divideAndRemainder(BigInteger.TWO);
|
||||
var modifier = divided[0]; if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
|
||||
return base.add(modifier);
|
||||
var modifier = divided[0];
|
||||
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
|
||||
return modifier;
|
||||
}
|
||||
|
||||
public static int targetBudget(BigInteger intelligence, Settings captured) {
|
||||
Objects.requireNonNull(intelligence); Objects.requireNonNull(captured);
|
||||
var earned = intelligence.subtract(BigInteger.valueOf(20)).max(BigInteger.ZERO).divide(captured.targetIntStep());
|
||||
return earned.add(BigInteger.valueOf(1L + captured.additionalTargets()))
|
||||
.min(BigInteger.valueOf(captured.maxTargets())).intValueExact();
|
||||
public static BigInteger totalDefinedDamage(BigInteger intelligence) {
|
||||
return intelligenceModifier(intelligence).multiply(DAMAGE_FACTOR);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
@@ -121,39 +119,76 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var captured = from(execution.definition()); var caster = execution.input().player();
|
||||
var purchasedInt = execution.character().stats().get(Stat.INT);
|
||||
var initial = selectInitial(execution.input(), captured.initialRange());
|
||||
if (initial == null) { execution.resolve("miss", Map.of("reason", "initial_target_unavailable")); return; }
|
||||
BigDecimal defined = new BigDecimal(initialDamage(captured.baseDamage(), execution.character().stats().get(Stat.INT)));
|
||||
var visited = new HashSet<UUID>(); LivingEntity current = initial, previous = null;
|
||||
var chainId = UUID.randomUUID(); int attempted = 0, accepted = 0;
|
||||
int maximum = targetBudget(execution.character().stats().get(Stat.INT), captured);
|
||||
for (int hop = 0; hop < maximum && current != null && execution.stillCurrent(); hop++) {
|
||||
if (!visited.add(current.getUniqueId()) || !eligible(caster, current)) break;
|
||||
var attempt = strike(execution, caster, current, defined, chainId, hop + 1, previous);
|
||||
attempted++; if (attempt.accepted) accepted++;
|
||||
visual(caster, current);
|
||||
if (hop + 1 == maximum) break;
|
||||
defined = defined.multiply(captured.jumpMultiplier());
|
||||
previous = current;
|
||||
current = next(caster, current, visited, captured.jumpRange());
|
||||
|
||||
var selected = discover(execution, caster, initial, captured);
|
||||
if (selected.isEmpty()) {
|
||||
execution.resolve(execution.stillCurrent() ? "miss" : "interrupted",
|
||||
Map.of("reason", execution.stillCurrent() ? "initial_target_became_ineligible" : "source_interrupted",
|
||||
"selected_chain_size", 0, "attempted_hops", 0, "accepted_hops", 0));
|
||||
return;
|
||||
}
|
||||
var outcome = new LinkedHashMap<String, Object>();
|
||||
outcome.put("chain_id", chainId.toString());
|
||||
var totalDefined = new BigDecimal(totalDefinedDamage(purchasedInt));
|
||||
var definedShare = totalDefined.divide(BigDecimal.valueOf(selected.size()), SHARE_PRECISION);
|
||||
double nativeShare = definedShare.doubleValue();
|
||||
var chainId = UUID.randomUUID();
|
||||
var common = new LinkedHashMap<String, Object>();
|
||||
common.put("chain_id", chainId.toString());
|
||||
common.put("selected_chain_size", selected.size());
|
||||
common.put("total_defined_pool", decimal(totalDefined));
|
||||
common.put("equal_defined_share", decimal(definedShare));
|
||||
execution.castEvidence(common);
|
||||
if (totalDefined.signum() <= 0 || !Double.isFinite(nativeShare) || nativeShare <= 0) {
|
||||
common.put("terminal_reason", "native_damage_not_finite");
|
||||
common.put("attempted_hops", 0); common.put("accepted_hops", 0);
|
||||
execution.resolve("failed", common); return;
|
||||
}
|
||||
|
||||
int attempted = 0, accepted = 0;
|
||||
for (int index = 0; index < selected.size(); index++) {
|
||||
var chosen = selected.get(index);
|
||||
if (!execution.stillCurrent()) break;
|
||||
var pathSource = chosen.previous() == null ? caster : chosen.previous();
|
||||
double range = chosen.previous() == null ? captured.initialRange() : captured.jumpRange();
|
||||
if (!eligible(caster, chosen.target()) || !inRangeAndVisible(pathSource, chosen.target(), range)) {
|
||||
visual(caster, chosen.target()); continue;
|
||||
}
|
||||
var attempt = strike(execution, caster, chosen.target(), nativeShare, totalDefined, definedShare,
|
||||
chainId, index + 1, selected.size(), chosen.previous());
|
||||
attempted++; if (attempt.accepted) accepted++;
|
||||
visual(caster, chosen.target());
|
||||
}
|
||||
var outcome = new LinkedHashMap<String, Object>(common);
|
||||
outcome.put("initial_target_id", initial.getUniqueId().toString());
|
||||
outcome.put("initial_target_location", locationData(initial.getLocation()));
|
||||
outcome.put("attempted_hops", attempted); outcome.put("accepted_hops", accepted);
|
||||
outcome.put("terminal_reason", !execution.stillCurrent() ? "source_interrupted"
|
||||
: attempted == maximum ? "target_limit" : "no_eligible_next_target");
|
||||
: selected.size() == captured.maxTargets() ? "target_limit" : "no_eligible_next_target");
|
||||
execution.resolve(!execution.stillCurrent() ? "interrupted" : accepted > 0 ? "hit" : "miss", outcome);
|
||||
}
|
||||
|
||||
private Attempt strike(AbilityExecution execution, Player caster, LivingEntity target,
|
||||
BigDecimal defined, UUID chainId, int ordinal, LivingEntity previous) {
|
||||
double amount = finite(definiteNonnegative(defined));
|
||||
private List<SelectedTarget> discover(AbilityExecution execution, Player caster, LivingEntity initial, Settings captured) {
|
||||
var selected = new ArrayList<SelectedTarget>(); var visited = new HashSet<UUID>();
|
||||
LivingEntity current = initial, previous = null;
|
||||
while (current != null && selected.size() < captured.maxTargets() && execution.stillCurrent()) {
|
||||
if (!visited.add(current.getUniqueId()) || !eligible(caster, current)) break;
|
||||
selected.add(new SelectedTarget(current, previous));
|
||||
if (selected.size() == captured.maxTargets()) break;
|
||||
previous = current; current = next(caster, current, visited, captured.jumpRange());
|
||||
}
|
||||
return List.copyOf(selected);
|
||||
}
|
||||
|
||||
private Attempt strike(AbilityExecution execution, Player caster, LivingEntity target, double amount,
|
||||
BigDecimal totalDefined, BigDecimal definedShare, UUID chainId, int ordinal,
|
||||
int selectedChainSize, LivingEntity previous) {
|
||||
var origin = caster.getEyeLocation().clone();
|
||||
var source = DamageSource.builder(DamageType.LIGHTNING_BOLT).withCausingEntity(caster)
|
||||
.withDirectEntity(caster).withDamageLocation(origin).build();
|
||||
var attempt = new Attempt(execution, target, amount, defined, chainId, ordinal, previous); damaging = attempt;
|
||||
var attempt = new Attempt(execution, target, amount, totalDefined, definedShare, chainId, ordinal,
|
||||
selectedChainSize, previous); damaging = attempt;
|
||||
try { execution.damage(target, amount, source); }
|
||||
finally { if (damaging == attempt) damaging = null; }
|
||||
return attempt;
|
||||
@@ -190,6 +225,8 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
|
||||
|
||||
private boolean eligible(Player caster, LivingEntity target) {
|
||||
if (target == caster) return false;
|
||||
try { if (target.isDead()) return false; }
|
||||
catch (RuntimeException unavailable) { return false; }
|
||||
if (!(target instanceof Player player)) return true;
|
||||
if (sessions.active(player.getUniqueId()).filter(sessions::current).isEmpty()) return false;
|
||||
var sourceParty = parties.partyOf(caster.getUniqueId()).orElse(null);
|
||||
@@ -218,8 +255,12 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
|
||||
var evidence = new LinkedHashMap<String, Object>();
|
||||
evidence.put("damage_kind", "owned_chain_strike");
|
||||
evidence.put("chain_id", attempt.chainId.toString());
|
||||
evidence.put("selected_chain_size", attempt.selectedChainSize);
|
||||
evidence.put("total_defined_pool", decimal(attempt.totalDefined));
|
||||
evidence.put("equal_defined_share", decimal(attempt.definedShare));
|
||||
evidence.put("hop_ordinal", attempt.ordinal);
|
||||
evidence.put("defined_amount", attempt.defined.stripTrailingZeros().toPlainString());
|
||||
evidence.put("defined_amount", decimal(attempt.definedShare));
|
||||
evidence.put("current_target_id", attempt.target.getUniqueId().toString());
|
||||
if (attempt.previousId != null) {
|
||||
evidence.put("previous_target_id", attempt.previousId.toString());
|
||||
evidence.put("previous_target_location", attempt.previousLocation);
|
||||
@@ -255,16 +296,10 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
|
||||
caster.getWorld().playSound(location, Sound.ENTITY_LIGHTNING_BOLT_THUNDER, 0.35f, 1.7f);
|
||||
} catch (RuntimeException ignored) { }
|
||||
}
|
||||
private static BigDecimal definiteNonnegative(BigDecimal value) { return value.signum() < 0 ? BigDecimal.ZERO : value; }
|
||||
private static double finite(BigDecimal value) {
|
||||
if (value.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0) return Double.MAX_VALUE;
|
||||
double result = value.doubleValue(); return Double.isFinite(result) ? result : Double.MAX_VALUE;
|
||||
}
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
var p = definition.parameters();
|
||||
return new Settings(p.get("base-damage").toBigIntegerExact(), definition.cooldown(), p.get("initial-range").doubleValue(),
|
||||
p.get("jump-range").doubleValue(), p.get("additional-targets").intValueExact(), p.get("jump-multiplier"),
|
||||
p.get("target-int-step").toBigIntegerExact(), p.get("max-targets").intValueExact());
|
||||
return new Settings(definition.cooldown(), p.get("initial-range").doubleValue(),
|
||||
p.get("jump-range").doubleValue(), p.get("max-targets").intValueExact());
|
||||
}
|
||||
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
|
||||
@Override public void close() { closed = true; damaging = null; }
|
||||
|
||||
@@ -151,7 +151,7 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
var current = settings;
|
||||
return new AbilityDefinition("frost_shard", "Frost Shard", Material.PRISMARINE_SHARD,
|
||||
List.of("Launch a frost projectile with a " + BigDecimal.valueOf(current.range()).stripTrailingZeros().toPlainString() + "-block travel limit.",
|
||||
"Deals " + current.baseDamage() + " + INT modifier damage and Slowness "
|
||||
"Deals 4 * (" + current.baseDamage() + " + INT modifier) damage and Slowness "
|
||||
+ level(current.slownessAmplifier()) + " for " + seconds(current.slownessDuration()) + "s."),
|
||||
"Use the equipped shard; a miss still spends the cooldown.",
|
||||
Map.of(Stat.INT, BigInteger.valueOf(12), Stat.WIS, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.ATK,
|
||||
@@ -187,7 +187,7 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO);
|
||||
BigInteger modifier = divided[0];
|
||||
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
|
||||
return base.add(modifier);
|
||||
return base.add(modifier).multiply(BigInteger.valueOf(4));
|
||||
}
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
|
||||
@@ -25,7 +25,7 @@ public final class ManualCraftDelivery {
|
||||
var outputs = records.orElseThrow().stream().map(sheets::create).toList();
|
||||
var grid = (CraftingInventory) current.getTopInventory();
|
||||
var inputs = grid.getMatrix();
|
||||
// Purpur 2618: these are native mirrors; count changes do not call slotsChanged.
|
||||
// Purpur 2651: these are native mirrors; count changes do not call slotsChanged.
|
||||
for (var item : inputs) if (item != null && !item.isEmpty()) item.setAmount(item.getAmount() - plan.quantity());
|
||||
if (plan.route() == ManualCraftPlan.Route.CURSOR) current.setCursor(outputs.getFirst());
|
||||
else for (int index = 0; index < outputs.size(); index++) {
|
||||
|
||||
@@ -94,6 +94,11 @@ public final class AbilityCastEventBridge {
|
||||
"native_velocity_status"))
|
||||
if (nativeEvidence.containsKey(key)) data.put(key, nativeEvidence.get(key));
|
||||
}
|
||||
if (cast.abilityId().equals("chain_lightning")) {
|
||||
for (String key : java.util.List.of("chain_id", "selected_chain_size", "total_defined_pool",
|
||||
"equal_defined_share"))
|
||||
if (nativeEvidence.containsKey(key)) data.put(key, nativeEvidence.get(key));
|
||||
}
|
||||
if (cast.abilityId().equals("longbow")) {
|
||||
data.put("draw_ticks", cast.heldTicks());
|
||||
data.put("draw_power", games.dmg.fantasystick.ability.Longbow.power(cast.heldTicks(),
|
||||
|
||||
@@ -33,7 +33,7 @@ public final class PartyManagement implements AutoCloseable {
|
||||
private void mutateParty(Player player,UUID id,java.util.function.BiFunction<Context,CharacterParty,java.util.concurrent.CompletableFuture<CharacterPartyDirectory.Result>> action,Consumer<Outcome> result){var context=context(player,id);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var party=parties.partyOf(context.orElseThrow().record().id()).orElse(null);if(party==null){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}try{action.apply(context.orElseThrow(),party).whenComplete((value,failure)->complete(value,failure,result));}catch(RuntimeException failure){fail(failure,result);}}
|
||||
private void complete(CharacterPartyDirectory.Result value,Throwable failure,Consumer<Outcome> result){if(failure!=null){fail(failure,result);return;}publish(()->result.accept(new Outcome(value.status()==CharacterPartyDirectory.Status.APPLIED?Status.APPLIED:map(value.status()),value.party())));}
|
||||
public int registrationPrice(){return prices.registrationPrice();}public int leaderChangePrice(){return prices.leaderChangePrice();}
|
||||
public Optional<CharacterRecord> active(Player player,UUID registrar){return context(player,registrar).map(Context::record);}public Optional<UUID> activeSession(Player player,UUID registrar){return context(player,registrar).map(value->value.active().session());}public boolean eligibleInvitationTarget(UUID character){return parties.partyOf(character).isEmpty()&&sessions.activeCharacter(character).filter(sessions::current).flatMap(sessions::currentRecord).filter(value->!value.dead()).isPresent();}public Optional<CharacterRecord> activeCharacter(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);return active==null?Optional.empty():sessions.currentRecord(active).filter(value->!value.dead());}
|
||||
public Optional<CharacterRecord> active(Player player,UUID registrar){return context(player,registrar).map(Context::record);}public Optional<UUID> activeSession(Player player,UUID registrar){return context(player,registrar).map(value->value.active().session());}public Status accessStatus(Player player,UUID registrarId,UUID character,UUID session){var registrar=registrars.find(registrarId).orElse(null);if(!parties.ready()||!recovered||registrar==null)return Status.INVALID_SESSION;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(record==null||!record.id().equals(character)||!active.session().equals(session))return Status.INVALID_SESSION;var location=player.getLocation();if(location.getWorld()==null||!location.getWorld().getUID().equals(registrar.world()))return Status.TOO_FAR;double dx=location.getX()-registrar.x(),dy=location.getY()-registrar.y(),dz=location.getZ()-registrar.z();return dx*dx+dy*dy+dz*dz>36?Status.TOO_FAR:Status.APPLIED;}public boolean eligibleInvitationTarget(UUID character){return parties.partyOf(character).isEmpty()&&sessions.activeCharacter(character).filter(sessions::current).flatMap(sessions::currentRecord).filter(value->!value.dead()).isPresent();}public Optional<CharacterRecord> activeCharacter(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);return active==null?Optional.empty():sessions.currentRecord(active).filter(value->!value.dead());}
|
||||
private Optional<Context> context(Player player,UUID registrarId){if(!parties.ready()||!recovered)return Optional.empty();var registrar=registrars.find(registrarId).orElse(null);var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(registrar==null||record==null)return Optional.empty();var location=player.getLocation();if(location.getWorld()==null||!location.getWorld().getUID().equals(registrar.world()))return Optional.empty();double dx=location.getX()-registrar.x(),dy=location.getY()-registrar.y(),dz=location.getZ()-registrar.z();if(dx*dx+dy*dy+dz*dz>36)return Optional.empty();return Optional.of(new Context(active,record,registrar));}
|
||||
private CharacterParty partyForInvitation(UUID invitation){return parties.all().stream().filter(party->party.invitations().stream().anyMatch(value->value.id().equals(invitation))).findFirst().orElse(null);}
|
||||
private games.dmg.fantasystick.events.PartyEventBridge.Action audit(Player player,Context context,String kind,UUID target,UUID invitation,CharacterParty before,BigInteger charge,UUID operation){return audit(player,context,kind,target,invitation,before,charge,operation,"requested");}
|
||||
|
||||
@@ -9,6 +9,8 @@ import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.inventory.InventoryCloseEvent;
|
||||
import org.bukkit.event.inventory.PrepareAnvilEvent;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.InventoryHolder;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
@@ -19,17 +21,24 @@ import org.bukkit.plugin.Plugin;
|
||||
public final class PartyRegistrarMenus implements Listener,AutoCloseable {
|
||||
private enum Screen{ROOT,NAME,INVITATIONS,OUTGOING,INVITE,MEMBERS}
|
||||
private record Holder(UUID registrar,UUID character,UUID session,Screen screen,int page) implements InventoryHolder{public Inventory getInventory(){return null;}}
|
||||
private final Server server;private final PartyManagement management;private final NamespacedKey actionKey,targetKey;private boolean closed;
|
||||
private record Submission(UUID player,UUID registrar,UUID character,UUID session){}
|
||||
private final Server server;private final PartyManagement management;private final NamespacedKey actionKey,targetKey;private final Set<Submission> submissions=new HashSet<>();private final Map<Inventory,Holder> nameScreens=new IdentityHashMap<>();private boolean closed;
|
||||
public PartyRegistrarMenus(Plugin plugin,PartyManagement management){server=plugin.getServer();this.management=Objects.requireNonNull(management);actionKey=new NamespacedKey(plugin,"party_registrar_action");targetKey=new NamespacedKey(plugin,"party_registrar_target");}
|
||||
public void open(Player player,UUID registrar){open(player,registrar,Screen.ROOT,0);}
|
||||
private void open(Player player,UUID registrar,Screen screen,int page){if(closed)return;var record=management.active(player,registrar);if(record.isEmpty()){player.sendMessage("You must be actively playing a living fantasy character beside this Party Registrar.");return;}if(screen==Screen.NAME){openName(player,registrar,record.orElseThrow().id(),management.activeSession(player,registrar).orElseThrow());return;}var inventory=server.createInventory(new Holder(registrar,record.orElseThrow().id(),management.activeSession(player,registrar).orElseThrow(),screen,page),screen==Screen.ROOT?27:54,net.kyori.adventure.text.Component.text("Party Registrar"));var party=management.party(player,registrar);if(screen==Screen.ROOT){if(party.isEmpty()){inventory.setItem(11,item(Material.WRITABLE_BOOK,"Register a party — "+management.registrationPrice()+" coins","register",null));inventory.setItem(15,item(Material.MAP,"Invitations","invitations",null));}else{var group=party.orElseThrow();inventory.setItem(4,item(Material.PAPER,"Party: "+group.displayName(),"info",null));if(group.leader().equals(record.orElseThrow().id())){inventory.setItem(10,item(Material.EMERALD,"Invite active characters","invite",null));inventory.setItem(13,item(Material.PLAYER_HEAD,"Manage members","members",null));inventory.setItem(16,item(Material.BARRIER,"Disband party","disband",null));inventory.setItem(22,item(Material.MAP,"Pending invitations","outgoing",null));}else inventory.setItem(13,item(Material.BARRIER,"Leave party","leave",null));}}else if(screen==Screen.INVITATIONS){var values=management.invitations(player,registrar);fill(inventory,page,values,value->item(Material.MAP,"Invitation (left accept, right decline)","invitation",value.id()));}else if(screen==Screen.OUTGOING){var values=management.outgoingInvitations(player,registrar);fill(inventory,page,values,value->item(Material.MAP,"Cancel invitation for "+value.character(),"cancel-invitation",value.id()));}else if(screen==Screen.INVITE){var values=server.getOnlinePlayers().stream().filter(Player::isOnline).map(target->management.activeCharacter(target).orElse(null)).filter(Objects::nonNull).filter(target->management.eligibleInvitationTarget(target.id())).map(value->value.id()).distinct().sorted().toList();fill(inventory,page,values,value->item(Material.PLAYER_HEAD,"Invite "+value,"invite-target",value));}else{var values=party.map(value->value.members().stream().filter(member->!member.equals(value.leader())).sorted().toList()).orElse(List.of());fill(inventory,page,values,value->item(Material.PLAYER_HEAD,"Member "+value,"member",value,List.of(net.kyori.adventure.text.Component.text("Left click: Kick (free)"),net.kyori.adventure.text.Component.text("Right click: Transfer leadership — "+management.leaderChangePrice()+" coins"))));}player.openInventory(inventory);}
|
||||
private <T> void fill(Inventory inventory,int page,List<T> values,java.util.function.Function<T,ItemStack> item){int start=Math.max(0,page)*45;for(int index=start;index<Math.min(values.size(),start+45);index++)inventory.setItem(index-start,item.apply(values.get(index)));if(page>0)inventory.setItem(45,item(Material.ARROW,"Previous","previous",null));if(start+45<values.size())inventory.setItem(53,item(Material.ARROW,"Next","next",null));}
|
||||
private void openName(Player player,UUID registrar,UUID character,UUID session){var inventory=server.createInventory(new Holder(registrar,character,session,Screen.NAME,0),org.bukkit.event.inventory.InventoryType.ANVIL,net.kyori.adventure.text.Component.text("Choose party name"));inventory.setItem(0,item(Material.PAPER,"Party name","name",null));player.openInventory(inventory);}
|
||||
private void openName(Player player,UUID registrar,UUID character,UUID session){var opened=org.bukkit.inventory.MenuType.ANVIL.builder().title(net.kyori.adventure.text.Component.text("Choose party name")).location(player.getLocation()).checkReachable(false).build(player);var inventory=opened.getTopInventory();nameScreens.put(inventory,new Holder(registrar,character,session,Screen.NAME,0));inventory.setItem(0,item(Material.PAPER,"Party name","name",null));free(opened);try{player.openInventory(opened);if(player.getOpenInventory()!=opened)nameScreens.remove(inventory);}catch(RuntimeException failure){nameScreens.remove(inventory);throw failure;}}
|
||||
private ItemStack item(Material material,String name,String action,UUID target){return item(material,name,action,target,List.of());}
|
||||
private ItemStack item(Material material,String name,String action,UUID target,List<net.kyori.adventure.text.Component> lore){var item=new ItemStack(material);var meta=item.getItemMeta();meta.customName(net.kyori.adventure.text.Component.text(name));if(!lore.isEmpty())meta.lore(lore);meta.getPersistentDataContainer().set(actionKey,PersistentDataType.STRING,action);if(target!=null)meta.getPersistentDataContainer().set(targetKey,PersistentDataType.STRING,target.toString());item.setItemMeta(meta);return item;}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof Holder holder)||!(event.getWhoClicked() instanceof Player player))return;event.setCancelled(true);if(closed)return;if(!management.active(player,holder.registrar()).map(value->value.id().equals(holder.character())).orElse(false)||!management.activeSession(player,holder.registrar()).map(holder.session()::equals).orElse(false)){player.sendMessage("Party action refused: the character or session changed.");return;}if(holder.screen()==Screen.NAME&&event.getRawSlot()==2){String name=event.getView() instanceof org.bukkit.inventory.view.AnvilView anvil?anvil.getRenameText():null;if(name!=null)management.register(player,holder.registrar(),name,outcome->respond(player,holder.registrar(),outcome,"Registered party."));return;}var clicked=event.getCurrentItem();if(clicked==null)return;var data=clicked.getItemMeta().getPersistentDataContainer();String action=data.get(actionKey,PersistentDataType.STRING);UUID target=uuid(data.get(targetKey,PersistentDataType.STRING));if("register".equals(action)){open(player,holder.registrar(),Screen.NAME,0);return;}if("invitations".equals(action)){open(player,holder.registrar(),Screen.INVITATIONS,0);return;}if("invite".equals(action)){open(player,holder.registrar(),Screen.INVITE,0);return;}if("outgoing".equals(action)){open(player,holder.registrar(),Screen.OUTGOING,0);return;}if("members".equals(action)){open(player,holder.registrar(),Screen.MEMBERS,0);return;}if("previous".equals(action)){open(player,holder.registrar(),holder.screen(),holder.page()-1);return;}if("next".equals(action)){open(player,holder.registrar(),holder.screen(),holder.page()+1);return;}if("leave".equals(action)){management.leave(player,holder.registrar(),outcome->respond(player,holder.registrar(),outcome,"Left party."));return;}if("disband".equals(action)){management.disband(player,holder.registrar(),outcome->respond(player,holder.registrar(),outcome,"Party ended."));return;}if(target==null)return;if("invite-target".equals(action)){management.invite(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation saved."));return;}if("cancel-invitation".equals(action)){management.cancel(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation cancelled."));return;}if("invitation".equals(action)){if(event.isRightClick())management.decline(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation declined."));else management.accept(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Joined party."));return;}if("member".equals(action)){if(event.isRightClick())management.transfer(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Leadership transferred."));else management.kick(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Member removed."));}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void drag(org.bukkit.event.inventory.InventoryDragEvent event){if(event.getInventory().getHolder() instanceof Holder)event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void prepare(PrepareAnvilEvent event){var holder=holder(event.getInventory());if(closed||holder==null||holder.screen()!=Screen.NAME)return;var source=event.getInventory().getItem(0);if(!action(source,"name")){event.setResult(null);return;}var result=source.clone();var meta=result.getItemMeta();String name=event.getView().getRenameText();meta.customName(net.kyori.adventure.text.Component.text(name==null||name.isBlank()?"Submit party name":name));result.setItemMeta(meta);event.setResult(result);free(event.getView());}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void click(InventoryClickEvent event){if(!(event.getWhoClicked() instanceof Player player))return;var holder=holder(event.getInventory());if(holder==null)return;event.setCancelled(true);if(closed)return;var access=management.accessStatus(player,holder.registrar(),holder.character(),holder.session());if(access!=PartyManagement.Status.APPLIED){player.sendMessage(access==PartyManagement.Status.TOO_FAR?"Party action refused: too far from the Party Registrar.":"Party action refused: the character or session changed.");return;}if(holder.screen()==Screen.NAME&&event.getRawSlot()==2){if(!(event.getView() instanceof org.bukkit.inventory.view.AnvilView anvil)||!action(event.getCurrentItem(),"name")){player.sendMessage("Party action refused: the name result is unavailable.");return;}var submission=new Submission(player.getUniqueId(),holder.registrar(),holder.character(),holder.session());if(!submissions.add(submission)){player.sendMessage("Party action refused: submission already in progress.");return;}String name=anvil.getRenameText();try{management.register(player,holder.registrar(),name==null?"":name,outcome->{submissions.remove(submission);respond(player,holder.registrar(),outcome,"Registered party.");});}catch(RuntimeException failure){submissions.remove(submission);throw failure;}return;}var clicked=event.getCurrentItem();if(clicked==null)return;var data=clicked.getItemMeta().getPersistentDataContainer();String action=data.get(actionKey,PersistentDataType.STRING);UUID target=uuid(data.get(targetKey,PersistentDataType.STRING));if("register".equals(action)){open(player,holder.registrar(),Screen.NAME,0);return;}if("invitations".equals(action)){open(player,holder.registrar(),Screen.INVITATIONS,0);return;}if("invite".equals(action)){open(player,holder.registrar(),Screen.INVITE,0);return;}if("outgoing".equals(action)){open(player,holder.registrar(),Screen.OUTGOING,0);return;}if("members".equals(action)){open(player,holder.registrar(),Screen.MEMBERS,0);return;}if("previous".equals(action)){open(player,holder.registrar(),holder.screen(),holder.page()-1);return;}if("next".equals(action)){open(player,holder.registrar(),holder.screen(),holder.page()+1);return;}if("leave".equals(action)){management.leave(player,holder.registrar(),outcome->respond(player,holder.registrar(),outcome,"Left party."));return;}if("disband".equals(action)){management.disband(player,holder.registrar(),outcome->respond(player,holder.registrar(),outcome,"Party ended."));return;}if(target==null)return;if("invite-target".equals(action)){management.invite(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation saved."));return;}if("cancel-invitation".equals(action)){management.cancel(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation cancelled."));return;}if("invitation".equals(action)){if(event.isRightClick())management.decline(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation declined."));else management.accept(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Joined party."));return;}if("member".equals(action)){if(event.isRightClick())management.transfer(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Leadership transferred."));else management.kick(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Member removed."));}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void drag(org.bukkit.event.inventory.InventoryDragEvent event){if(holder(event.getInventory())!=null)event.setCancelled(true);}
|
||||
@EventHandler public void close(InventoryCloseEvent event){clearName(event.getInventory());}
|
||||
private void respond(Player player,UUID registrar,PartyManagement.Outcome outcome,String success){if(!player.isOnline())return;if(outcome.status()==PartyManagement.Status.APPLIED){player.sendMessage(success);open(player,registrar);}else player.sendMessage("Party action refused: "+outcome.status().name().toLowerCase(Locale.ROOT).replace('_',' ')+".");}
|
||||
private Holder holder(Inventory inventory){return inventory.getHolder() instanceof Holder holder?holder:nameScreens.get(inventory);}
|
||||
private void clearName(Inventory inventory){if(nameScreens.remove(inventory)==null)return;for(int slot:new int[]{0,2})if(action(inventory.getItem(slot),"name"))inventory.setItem(slot,null);}
|
||||
private boolean action(ItemStack item,String expected){return item!=null&&item.hasItemMeta()&&expected.equals(item.getItemMeta().getPersistentDataContainer().get(actionKey,PersistentDataType.STRING));}
|
||||
private static void free(org.bukkit.inventory.view.AnvilView anvil){anvil.setRepairItemCountCost(0);anvil.setRepairCost(0);anvil.setMaximumRepairCost(Integer.MAX_VALUE);anvil.setBypassCost(true);}
|
||||
private static UUID uuid(String value){try{return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}}
|
||||
@Override public void close(){closed=true;}
|
||||
@Override public void close(){closed=true;submissions.clear();for(var inventory:List.copyOf(nameScreens.keySet()))clearName(inventory);}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import org.bukkit.craftbukkit.potion.CraftPotionEffectType;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Read-only Purpur 2618 phase/instance proof. Never changes native maps, events or mutation flags. */
|
||||
/** Read-only Purpur 2651 phase/instance proof. Never changes native maps, events or mutation flags. */
|
||||
final class NativePotionState {
|
||||
private static final Field TICKING = tickingField();
|
||||
private static final StackWalker STACK = StackWalker.getInstance();
|
||||
|
||||
@@ -28,17 +28,13 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
/** Serialized durable Chain Lightning settings; publication remains a server-thread caller decision. */
|
||||
public final class ChainLightningSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String BASE = "chain-lightning-base-damage";
|
||||
public static final String COOLDOWN = "chain-lightning-cooldown-seconds";
|
||||
public static final String INITIAL_RANGE = "chain-lightning-initial-range-blocks";
|
||||
public static final String JUMP_RANGE = "chain-lightning-jump-range-blocks";
|
||||
public static final String ADDITIONAL = "chain-lightning-additional-targets";
|
||||
public static final String MULTIPLIER = "chain-lightning-jump-multiplier";
|
||||
public static final String INT_STEP = "chain-lightning-target-int-step";
|
||||
public static final String MAX_TARGETS = "chain-lightning-max-targets";
|
||||
private static final List<String> KEYS = List.of(BASE, COOLDOWN, INITIAL_RANGE, JUMP_RANGE, ADDITIONAL, MULTIPLIER, INT_STEP, MAX_TARGETS);
|
||||
private static final List<String> KEYS = List.of(COOLDOWN, INITIAL_RANGE, JUMP_RANGE, MAX_TARGETS);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(
|
||||
BASE, "8", COOLDOWN, "18", INITIAL_RANGE, "16", JUMP_RANGE, "6", ADDITIONAL, "4", MULTIPLIER, "0.75", INT_STEP, "5", MAX_TARGETS, "12");
|
||||
COOLDOWN, "18", INITIAL_RANGE, "16", JUMP_RANGE, "6", MAX_TARGETS, "20");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, ChainLightning.Settings settings, Map<String, String> values, long sequence) {
|
||||
@@ -112,10 +108,9 @@ public final class ChainLightningSettings implements AutoCloseable {
|
||||
}
|
||||
|
||||
public static ChainLightning.Settings parse(Map<String, String> values) {
|
||||
try { return new ChainLightning.Settings(new BigDecimal(values.get(BASE)).toBigIntegerExact(),
|
||||
seconds(values.get(COOLDOWN)), finite(values.get(INITIAL_RANGE)), finite(values.get(JUMP_RANGE)),
|
||||
new BigDecimal(values.get(ADDITIONAL)).intValueExact(), new BigDecimal(values.get(MULTIPLIER)),
|
||||
new BigDecimal(values.get(INT_STEP)).toBigIntegerExact(), new BigDecimal(values.get(MAX_TARGETS)).intValueExact()); }
|
||||
try { return new ChainLightning.Settings(seconds(values.get(COOLDOWN)),
|
||||
finite(values.get(INITIAL_RANGE)), finite(values.get(JUMP_RANGE)),
|
||||
new BigDecimal(values.get(MAX_TARGETS)).intValueExact()); }
|
||||
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Chain Lightning settings must use supported numeric values", invalid);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import net.minecraft.world.level.GameType;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/**
|
||||
* Display-only codec pinned to Purpur 26.2 build 2618. No packet delivery, world insertion,
|
||||
* Display-only codec pinned to Purpur 26.3 build 2651. No packet delivery, world insertion,
|
||||
* profile/team changes, or authoritative player mutations belong here.
|
||||
*
|
||||
* <p>Player reads and detached-label creation/updates belong on the server thread. Pure
|
||||
|
||||
@@ -445,7 +445,7 @@ public final class ProtocolPresentation implements CharacterPresentation.Output
|
||||
}
|
||||
}
|
||||
} else if (packet instanceof ClientboundRemoveEntitiesPacket removal
|
||||
&& removal.getEntityIds().contains(decision.entity())) { mask |= UNTRACKED; }
|
||||
&& removal.entityIds().contains(decision.entity())) { mask |= UNTRACKED; }
|
||||
else if (packet instanceof ClientboundPlayerInfoRemovePacket removal
|
||||
&& removal.profileIds().contains(view.holder())) { mask |= TAB_REMOVED; }
|
||||
else if (packet instanceof ClientboundRespawnPacket) { mask |= UNTRACKED; }
|
||||
|
||||
@@ -176,7 +176,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
int slot = player.getInventory().getHeldItemSlot();
|
||||
if (reservedExchange(player, slot)) {
|
||||
event.setCancelled(true);
|
||||
// Purpur 2618 snapshots the proposed hand stacks BEFORE dispatch. Never let its
|
||||
// Purpur 2651 snapshots the proposed hand stacks BEFORE dispatch. Never let its
|
||||
// continuation write the old fantasy control over the restored ordinary stack.
|
||||
if (Objects.equals(event.getMainHandItem(), player.getInventory().getItemInOffHand())
|
||||
&& Objects.equals(event.getOffHandItem(), player.getInventory().getItem(slot))) {
|
||||
@@ -408,7 +408,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
try {
|
||||
boolean matching = draw != null && draw.item().equals(event.getItem())
|
||||
&& draw.item().equals(player.getActiveItem()) && event.getTicksHeldFor() >= 0;
|
||||
// Purpur 2618 reloads LivingEntity.useItem AFTER this event, then calls releaseUsing.
|
||||
// Purpur 2651 reloads LivingEntity.useItem AFTER this event, then calls releaseUsing.
|
||||
// Clear it first: cancelling EntityShootBowEvent alone is too late to own ammo draw.
|
||||
if (special(event.getItem()) && event.getItem().equals(player.getActiveItem())) player.clearActiveItem();
|
||||
if (matching) emit(player, draw.item(), draw.selected(), ControlInput.Gesture.DRAW_RELEASE,
|
||||
@@ -542,7 +542,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
|| !Arrays.equals(BOX_SLOT_MAP, item.getPersistentDataContainer().get(BOX_SLOTS, PersistentDataType.INTEGER_ARRAY))
|
||||
|| !(entity instanceof Player player) || !box.owner().equals(player.getUniqueId())
|
||||
|| player.getInventory().getMaxStackSize() < 1) return true;
|
||||
// Purpur 2618 Inventory.add(-1, stack): factory nonstackable boxes cannot merge;
|
||||
// Purpur 2651 Inventory.add(-1, stack): factory nonstackable boxes cannot merge;
|
||||
// getFreeSlot scans 0..35, NOT the selected slot or only main storage. Read only:
|
||||
// leave native insertion to the caller, and never invent a recovery destination.
|
||||
for (int slot = 0; slot < 36; slot++) {
|
||||
|
||||
@@ -624,19 +624,22 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
if (hasRemnants(attempt.player) || !gameModes.available(attempt.player)
|
||||
|| vitals != null && !vitals.available(attempt.player)) return false;
|
||||
var marker = EquipmentSessionMarker.read(attempt.player).orElse(null);
|
||||
if (EquipmentSessionMarker.present(attempt.player)) {
|
||||
// A terminal marker is resolved only by its matching completed file acknowledgment.
|
||||
// Keep it until the next successful entry; worker acknowledgments never touch PDC.
|
||||
var exit = state == null ? null : state.lastExit();
|
||||
if (marker == null || marker.phase() != EquipmentSessionMarker.Phase.RESTORED
|
||||
|| state == null || state.pending() != null || exit == null
|
||||
|| !marker.matches(exit.sessionId(), exit.characterId())) return false;
|
||||
}
|
||||
if (EquipmentSessionMarker.present(attempt.player) && !completedRestoredMarker(marker, state)) return false;
|
||||
attempt.entryMarker = marker;
|
||||
attempt.markerValidated = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean completedRestoredMarker(EquipmentSessionMarker.Token marker,
|
||||
EquipmentSessionStore.State state) {
|
||||
// A terminal marker is resolved only by its matching completed file acknowledgment.
|
||||
// Keep it until the next successful entry; worker acknowledgments never touch PDC.
|
||||
var exit = state == null ? null : state.lastExit();
|
||||
return marker != null && marker.phase() == EquipmentSessionMarker.Phase.RESTORED
|
||||
&& state != null && state.pending() == null && exit != null
|
||||
&& marker.matches(exit.sessionId(), exit.characterId());
|
||||
}
|
||||
|
||||
private static boolean hasRemnants(Player player) {
|
||||
return Arrays.stream(player.getInventory().getContents()).anyMatch(EquipmentSessions::remnant)
|
||||
|| remnant(player.getItemOnCursor());
|
||||
@@ -1940,7 +1943,9 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
var arrivingOwner = attempt.owner;
|
||||
var episode=downedDirectory==null?null:downedDirectory.all().stream().filter(value->value.player().equals(arrivingOwner)&&value.phase()==DownedEpisode.Phase.PENDING).findFirst().orElse(null);
|
||||
if (sheets.identify(attempt.player.getInventory().getItemInOffHand()).isPresent()) enter(attempt.player,episode);
|
||||
else if (EquipmentSessionMarker.present(attempt.player) || hasRemnants(attempt.player)) {
|
||||
else if (hasRemnants(attempt.player)
|
||||
|| (EquipmentSessionMarker.present(attempt.player)
|
||||
&& !completedRestoredMarker(EquipmentSessionMarker.read(attempt.player).orElse(null), state.orElse(null)))) {
|
||||
notice(attempt.owner, "NATIVE_RECOVERY_REQUIRED", "Native equipment remnants have no pending receipt; items were retained for inspection.");
|
||||
}
|
||||
return; // Ordinary no-sheet/no-receipt arrivals are not rejected entry attempts.
|
||||
|
||||
@@ -5,7 +5,7 @@ import java.util.Objects;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
|
||||
/**
|
||||
* Purpur 2618 main-storage-only exchange, called on the server thread after revalidation.
|
||||
* Purpur 2651 main-storage-only exchange, called on the server thread after revalidation.
|
||||
* Public Craft/native accessors (as with NativeHealthLimit), no private-field access.
|
||||
* Native NonNullList.set only replaces a list reference: no item copying, packet emission,
|
||||
* equip notification, menu recomputation or plugin callback between the two writes.
|
||||
|
||||
@@ -6,7 +6,7 @@ import java.util.function.Consumer;
|
||||
import net.minecraft.world.entity.ai.attributes.AttributeInstance;
|
||||
|
||||
/**
|
||||
* Purpur 2618 instance-local effective-value lease. Base/modifiers remain in the native
|
||||
* Purpur 2651 instance-local effective-value lease. Base/modifiers remain in the native
|
||||
* instance, so additions, removals, potion expiry and serialization keep their normal owners.
|
||||
* The pinned dirty callback is synchronous: no polling interval can expose an external maximum.
|
||||
* No global attribute/range is changed. Native compatibility tests must gate runtime upgrades.
|
||||
|
||||
@@ -212,16 +212,13 @@ frost-nova-radius-blocks: 5
|
||||
frost-nova-slowness-amplifier: 1
|
||||
frost-nova-slowness-duration-seconds: 3
|
||||
|
||||
# Chain Lightning defaults from the reviewed remaining-development packet.
|
||||
# Chain Lightning effective settings. Damage is always four times the purchased-INT modifier,
|
||||
# divided equally across the discovered chain. Obsolete damage/progression keys are ignored.
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
chain-lightning-base-damage: 8
|
||||
chain-lightning-cooldown-seconds: 18
|
||||
chain-lightning-initial-range-blocks: 16
|
||||
chain-lightning-jump-range-blocks: 6
|
||||
chain-lightning-additional-targets: 4
|
||||
chain-lightning-jump-multiplier: 0.75
|
||||
chain-lightning-target-int-step: 5
|
||||
chain-lightning-max-targets: 12
|
||||
chain-lightning-max-targets: 20
|
||||
|
||||
# Sanctuary defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: FantasyStick
|
||||
version: ${version}
|
||||
main: games.dmg.fantasystick.FantasyStickPlugin
|
||||
api-version: "26.2"
|
||||
api-version: "26.3"
|
||||
depend: [ProtocolLib]
|
||||
description: Transferable characters, native fantasy health, recoverable equipment, character books and parties.
|
||||
author: dmg.games
|
||||
|
||||
+1
-1
@@ -166,7 +166,7 @@ class AdminActionConfigurationCatalogNativeTest {
|
||||
new String[]{"hex-duration-seconds", "5"}, new String[]{"inspire-base-temporary-health", "5"},
|
||||
new String[]{"mending-light-base-healing", "7"}, new String[]{"purify-range-blocks", "13"},
|
||||
new String[]{"guardian-bond-range-blocks", "11"}, new String[]{"rallying-cry-radius-blocks", "9"},
|
||||
new String[]{"frost-nova-radius-blocks", "6"}, new String[]{"chain-lightning-base-damage", "9"},
|
||||
new String[]{"frost-nova-radius-blocks", "6"}, new String[]{"chain-lightning-max-targets", "19"},
|
||||
new String[]{"sanctuary-radius-blocks", "7"}, new String[]{"war-banner-radius-blocks", "9"},
|
||||
new String[]{"meteor-base-damage", "15"});
|
||||
|
||||
|
||||
@@ -43,17 +43,13 @@ class ChainLightningCoverageNativeTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
static Stream<Arguments> coverage() {
|
||||
return Stream.of(
|
||||
Arguments.of("20", "", 5, false), Arguments.of("24", "", 5, false),
|
||||
Arguments.of("25", "", 6, false), Arguments.of("29", "", 6, false),
|
||||
Arguments.of("30", "", 7, false), Arguments.of("34", "", 7, false),
|
||||
Arguments.of("35", "", 8, false), Arguments.of("39", "", 8, false),
|
||||
Arguments.of("54", "", 11, false), Arguments.of("55", "", 12, true),
|
||||
Arguments.of(BigInteger.TEN.pow(500).toString(), "", 12, false),
|
||||
Arguments.of("30", "chain-lightning-additional-targets: 3\n", 6, false),
|
||||
Arguments.of("30", "chain-lightning-target-int-step: 2\n", 10, false),
|
||||
Arguments.of("55", "chain-lightning-max-targets: 2\n", 2, false),
|
||||
Arguments.of("25", "chain-lightning-additional-targets: 0\n", 2, false),
|
||||
Arguments.of("20", "chain-lightning-additional-targets: 63\nchain-lightning-max-targets: 64\n", 64, false));
|
||||
Arguments.of("20", "chain-lightning-max-targets: 1\n", 1, false),
|
||||
Arguments.of("20", "chain-lightning-max-targets: 3\n", 3, false),
|
||||
Arguments.of("20", "", 20, true),
|
||||
Arguments.of("30", "chain-lightning-max-targets: 5\n", 5, false),
|
||||
Arguments.of("30", "chain-lightning-max-targets: 2\n", 2, false),
|
||||
Arguments.of("20", "chain-lightning-base-damage: 999\nchain-lightning-additional-targets: 0\n"
|
||||
+ "chain-lightning-jump-multiplier: 0.01\nchain-lightning-target-int-step: 1\n", 20, false));
|
||||
}
|
||||
@ParameterizedTest @MethodSource("coverage")
|
||||
void purchasedIntAndCapturedSettingsProduceActualUniqueHopsAndTruthfulDamage(
|
||||
@@ -61,7 +57,7 @@ class ChainLightningCoverageNativeTest {
|
||||
exercise(purchasedInt, retained, expected, cancelThird, false);
|
||||
}
|
||||
@Test void pendingCastKeepsCapturedPolicyWhileAcknowledgedAdminUpdateAffectsOnlyNextCast() throws Exception {
|
||||
exercise("55", "", 12, false, true);
|
||||
exercise("30", "", 20, false, true);
|
||||
}
|
||||
private void exercise(String purchasedInt, String retained, int expected, boolean cancelThird, boolean changeDuringSave) throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "adventure-daily-site-count: 0\nchain-lightning-cooldown-seconds: 0\n" + retained);
|
||||
@@ -74,7 +70,7 @@ class ChainLightningCoverageNativeTest {
|
||||
var capturedDefinition = ((AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry")).find("chain_lightning").orElseThrow();
|
||||
PartyBookNativeTest.click(f, caster, learn(caster)); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var targets = new ArrayList<LivingEntity>();
|
||||
for (int index = 0; index < Math.max(13, expected + 1); index++) {
|
||||
for (int index = 0; index < Math.max(21, expected + 1); index++) {
|
||||
var target = f.own(mock(LivingEntity.class)); targets.add(target);
|
||||
when(target.getUniqueId()).thenReturn(new UUID(0, index + 1)); when(target.getName()).thenReturn("Coverage " + index);
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.HUSK);
|
||||
@@ -132,11 +128,14 @@ class ChainLightningCoverageNativeTest {
|
||||
assertEquals(expected - (cancelThird ? 1 : 0), applied.size());
|
||||
verify(targets.get(expected), never()).damage(anyDouble(), any(DamageSource.class));
|
||||
var parameters = capturedDefinition.parameters();
|
||||
int base = Math.min(parameters.get("max-targets").intValueExact(), 1 + parameters.get("additional-targets").intValueExact());
|
||||
// BookPagination wraps by glyph width, including within words; preserve all actual text/numbers.
|
||||
assertTrue(book.contains(base + "basetotaltargetsatINT20"), "book must explain the actual captured base");
|
||||
assertTrue(book.contains("per" + parameters.get("target-int-step").toPlainString() + "purchasedINTabove20"));
|
||||
assertTrue(book.contains("cappedat" + parameters.get("max-targets").toPlainString() + "totaltargets"));
|
||||
assertEquals(expected, parameters.get("max-targets").intValueExact());
|
||||
assertEquals(new BigDecimal("4"), parameters.get("int-modifier-damage-factor"));
|
||||
assertFalse(parameters.keySet().stream().anyMatch(key -> List.of("base-damage", "additional-targets",
|
||||
"jump-multiplier", "target-int-step").contains(key)));
|
||||
// BookPagination wraps by glyph width, including within words; preserve the effective formula and ceiling.
|
||||
assertTrue(book.contains("fourtimesyourpurchasedINTmodifier"));
|
||||
assertTrue(book.contains("divide thatpoolequally".replace(" ", "")));
|
||||
assertTrue(book.contains("upto" + expected + "targets"));
|
||||
var saved = new YamlCharacterRepository(data).load(caster.core.record.id()).orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(39), saved.unspentPoints()); assertTrue(saved.learned().contains("chain_lightning"));
|
||||
assertEquals("chain_lightning", saved.loadout().getFirst()); assertNotNull(saved.cooldowns().get("chain_lightning"));
|
||||
@@ -145,17 +144,28 @@ class ChainLightningCoverageNativeTest {
|
||||
catch (Exception failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
var hits = rows("combat.damage"); assertEquals(applied.size(), hits.size());
|
||||
var total = new BigDecimal(ChainLightning.totalDefinedDamage(new BigInteger(purchasedInt)));
|
||||
var share = total.divide(BigDecimal.valueOf(expected), java.math.MathContext.DECIMAL128);
|
||||
assertTrue(attempted.values().stream().allMatch(value -> Math.abs(value - share.doubleValue()) < 1e-9));
|
||||
for (var row : hits) {
|
||||
var facts = row.getAsJsonObject("data"); int ordinal = facts.get("hop_ordinal").getAsInt();
|
||||
var defined = new BigDecimal(ChainLightning.initialDamage(new BigInteger(purchasedInt)))
|
||||
.multiply(new BigDecimal("0.75").pow(ordinal - 1));
|
||||
assertEquals(defined.stripTrailingZeros().toPlainString(), facts.get("defined_amount").getAsString());
|
||||
assertEquals(total.stripTrailingZeros().toPlainString(), facts.get("total_defined_pool").getAsString());
|
||||
assertEquals(share.stripTrailingZeros().toPlainString(), facts.get("equal_defined_share").getAsString());
|
||||
assertEquals(share.stripTrailingZeros().toPlainString(), facts.get("defined_amount").getAsString());
|
||||
assertEquals(expected, facts.get("selected_chain_size").getAsInt());
|
||||
assertEquals(targets.get(ordinal - 1).getUniqueId().toString(), facts.get("current_target_id").getAsString());
|
||||
assertEquals(applied.get(targets.get(ordinal - 1).getUniqueId()), facts.get("actual_amount").getAsDouble(), 1e-9);
|
||||
assertTrue(facts.has("target_location")); if (ordinal > 1) assertTrue(facts.has("previous_target_id"));
|
||||
if (cancelThird) assertNotEquals(3, ordinal);
|
||||
}
|
||||
assertEquals(hits.size(), hits.stream().map(row -> row.getAsJsonObject("data").get("application_id").getAsString()).distinct().count());
|
||||
var cast = rows("ability.cast").getFirst().getAsJsonObject("data");
|
||||
var resolved = rows("ability.resolved").getFirst().getAsJsonObject("data");
|
||||
for (var evidence : List.of(cast, resolved)) {
|
||||
assertEquals(expected, evidence.get("selected_chain_size").getAsInt());
|
||||
assertEquals(total.stripTrailingZeros().toPlainString(), evidence.get("total_defined_pool").getAsString());
|
||||
assertEquals(share.stripTrailingZeros().toPlainString(), evidence.get("equal_defined_share").getAsString());
|
||||
}
|
||||
assertEquals(expected, resolved.get("attempted_hops").getAsInt()); assertEquals(applied.size(), resolved.get("accepted_hops").getAsInt());
|
||||
assertEquals(1, rows("ability.cast").size(), "duplicate pending input never starts another business cast");
|
||||
if (changeDuringSave) {
|
||||
@@ -175,47 +185,55 @@ class ChainLightningCoverageNativeTest {
|
||||
}
|
||||
}
|
||||
}
|
||||
@Test void newConfigurationKeysUseRegisteredPermissionCheckedDurableReadUpdateCompletionAndReload() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "adventure-daily-site-count: 0\nchain-lightning-additional-targets: 3\nunrelated: retained\n");
|
||||
@Test void effectiveConfigurationUsesRegisteredDurableSurfaceAndPreservesIgnoredLegacyKeys() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "adventure-daily-site-count: 0\nchain-lightning-base-damage: 999\n"
|
||||
+ "chain-lightning-additional-targets: 3\nchain-lightning-jump-multiplier: 0.25\n"
|
||||
+ "chain-lightning-target-int-step: 1\nunrelated: retained\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-"}).isEmpty());
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-target-int-step", "7"});
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", ChainLightningSettings.MAX_TARGETS, "2"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
var completions = f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-"});
|
||||
assertTrue(completions.containsAll(List.of("chain-lightning-target-int-step", "chain-lightning-max-targets")), "new keys must be wired through real command completion");
|
||||
assertEquals(List.of(ChainLightningSettings.COOLDOWN, ChainLightningSettings.INITIAL_RANGE,
|
||||
ChainLightningSettings.JUMP_RANGE, ChainLightningSettings.MAX_TARGETS), completions);
|
||||
assertTrue(completions.stream().noneMatch(key -> List.of("chain-lightning-base-damage",
|
||||
"chain-lightning-additional-targets", "chain-lightning-jump-multiplier",
|
||||
"chain-lightning-target-int-step").contains(key)));
|
||||
var store = (ChainLightningSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "chainLightningSettings");
|
||||
assertEquals("3", store.value(ChainLightningSettings.ADDITIONAL), "retained explicit base is never silently replaced");
|
||||
for (var entry : Map.of("chain-lightning-target-int-step", "7", "chain-lightning-max-targets", "2").entrySet()) {
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", entry.getKey(), entry.getValue()});
|
||||
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
|
||||
assertEquals(entry.getValue(), store.value(entry.getKey()));
|
||||
}
|
||||
assertEquals(List.of("5", "7"), f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-target-int-step", ""}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-max-targets", "2", "extra"}).isEmpty());
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-target-int-step"});
|
||||
assertTrue(sender.messages.getLast().contains("=7"));
|
||||
assertEquals("20", store.value(ChainLightningSettings.MAX_TARGETS));
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", ChainLightningSettings.MAX_TARGETS, "2"});
|
||||
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
|
||||
assertEquals("2", store.value(ChainLightningSettings.MAX_TARGETS));
|
||||
assertEquals(List.of("2", "20"), f.command.tabComplete(sender.port, "fstickadmin",
|
||||
new String[]{"config", ChainLightningSettings.MAX_TARGETS, ""}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fstickadmin",
|
||||
new String[]{"config", ChainLightningSettings.MAX_TARGETS, "2", "extra"}).isEmpty());
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", ChainLightningSettings.MAX_TARGETS});
|
||||
assertTrue(sender.messages.getLast().contains("=2"));
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"), java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid()); f.command.execute(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-target-int-step", "1"});
|
||||
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
|
||||
assertTrue(lock.isValid()); f.command.execute(sender.port, "fstickadmin",
|
||||
new String[]{"config", ChainLightningSettings.MAX_TARGETS, "1"});
|
||||
callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
|
||||
}
|
||||
assertEquals("7", store.value("chain-lightning-target-int-step")); assertTrue(sender.messages.getLast().contains("save failed"));
|
||||
for (String[] invalid : List.of(new String[]{"chain-lightning-target-int-step", "0"}, new String[]{"chain-lightning-target-int-step", "1.5"},
|
||||
new String[]{"chain-lightning-max-targets", "0"}, new String[]{"chain-lightning-max-targets", "65"})) {
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", invalid[0], invalid[1]}); assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
assertEquals("2", store.value(ChainLightningSettings.MAX_TARGETS)); assertTrue(sender.messages.getLast().contains("save failed"));
|
||||
for (String invalid : List.of("0", "21", "1.5")) {
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", ChainLightningSettings.MAX_TARGETS, invalid});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
}
|
||||
assertTrue(Files.readString(data.resolve("config.yml")).contains("retained"));
|
||||
String savedFile = Files.readString(data.resolve("config.yml"));
|
||||
assertTrue(savedFile.contains("retained")); assertTrue(savedFile.contains("chain-lightning-base-damage"));
|
||||
var registry = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(new BigDecimal("7"), registry.find("chain_lightning").orElseThrow().parameters().get("target-int-step"));
|
||||
assertEquals(new BigDecimal("2"), registry.find("chain_lightning").orElseThrow().parameters().get("max-targets"));
|
||||
var parameters = registry.find("chain_lightning").orElseThrow().parameters();
|
||||
assertEquals(new BigDecimal("2"), parameters.get("max-targets"));
|
||||
assertFalse(parameters.containsKey("base-damage")); assertFalse(parameters.containsKey("target-int-step"));
|
||||
ReskillEventsNativeTest.await(f, () -> {
|
||||
try { return configRows().size() == 3; } catch (Exception failure) { throw new AssertionError(failure); }
|
||||
try { return configRows().size() == 2; } catch (Exception failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
var restarted = (ChainLightningSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "chainLightningSettings");
|
||||
assertEquals("7", restarted.value("chain-lightning-target-int-step")); assertEquals("2", restarted.value("chain-lightning-max-targets"));
|
||||
assertEquals("3", restarted.value(ChainLightningSettings.ADDITIONAL));
|
||||
assertEquals("2", restarted.value(ChainLightningSettings.MAX_TARGETS));
|
||||
}
|
||||
}
|
||||
private void seed(FantasyStickLifecycleNativeTest.Actor actor, BigInteger intelligence) throws Exception {
|
||||
@@ -240,6 +258,7 @@ class ChainLightningCoverageNativeTest {
|
||||
}
|
||||
private List<com.google.gson.JsonObject> configRows() throws Exception {
|
||||
return ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString().endsWith("config.read") || row.get("type").getAsString().endsWith("config.changed"))
|
||||
.filter(row -> row.getAsJsonObject("data").has("key") && List.of("chain-lightning-target-int-step", "chain-lightning-max-targets").contains(row.getAsJsonObject("data").get("key").getAsString())).toList();
|
||||
.filter(row -> row.getAsJsonObject("data").has("key")
|
||||
&& ChainLightningSettings.MAX_TARGETS.equals(row.getAsJsonObject("data").get("key").getAsString())).toList();
|
||||
}
|
||||
}
|
||||
@@ -59,9 +59,9 @@ class ChainLightningNativeTest {
|
||||
assertEquals(2, viewer.particleCalls.stream().filter(call -> call[0] == org.bukkit.Particle.ELECTRIC_SPARK
|
||||
&& call[2].equals(18)).count(), "An enabled nearby viewer retains every chain burst");
|
||||
assertFalse(damage.isEmpty(), caster.messages.toString());
|
||||
assertEquals(13, damage.get(first.getUniqueId()), 1.0e-10); assertEquals(9.75, damage.get(second.getUniqueId()), 1.0e-10);
|
||||
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 13 damage to Zombie."::equals).count());
|
||||
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 9.75 damage to Skeleton."::equals).count());
|
||||
assertEquals(10, damage.get(first.getUniqueId()), 1.0e-10); assertEquals(10, damage.get(second.getUniqueId()), 1.0e-10);
|
||||
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 10 damage to Zombie."::equals).count());
|
||||
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 10 damage to Skeleton."::equals).count());
|
||||
var stored = ((games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters"))
|
||||
.find(caster.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(39), stored.unspentPoints()); assertTrue(stored.learned().contains("chain_lightning"));
|
||||
@@ -76,8 +76,10 @@ class ChainLightningNativeTest {
|
||||
"The generic native observer must not repeat feature-owned chain hits");
|
||||
assertEquals(List.of(1, 2), hits.stream().map(event -> event.getAsJsonObject("data").get("hop_ordinal").getAsInt()).toList());
|
||||
assertEquals(2, hits.stream().map(event -> event.getAsJsonObject("data").get("application_id").getAsString()).distinct().count());
|
||||
assertEquals(13, hits.getFirst().getAsJsonObject("data").get("actual_amount").getAsDouble(), 1.0e-9);
|
||||
assertEquals(9.75, hits.getLast().getAsJsonObject("data").get("actual_amount").getAsDouble(), 1.0e-9);
|
||||
assertTrue(hits.stream().allMatch(event -> event.getAsJsonObject("data").get("actual_amount").getAsDouble() == 10));
|
||||
assertTrue(hits.stream().allMatch(event -> event.getAsJsonObject("data").get("selected_chain_size").getAsInt() == 2));
|
||||
assertTrue(hits.stream().allMatch(event -> event.getAsJsonObject("data").get("total_defined_pool").getAsString().equals("20")));
|
||||
assertTrue(hits.stream().allMatch(event -> event.getAsJsonObject("data").get("equal_defined_share").getAsString().equals("10")));
|
||||
assertTrue(hits.stream().allMatch(event -> event.getAsJsonObject("data").has("target_location")));
|
||||
assertEquals("hit", abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data").get("outcome").getAsString());
|
||||
}
|
||||
@@ -127,7 +129,8 @@ class ChainLightningNativeTest {
|
||||
}
|
||||
var records = events(data); var hits = abilityEvents(records, "combat.damage");
|
||||
assertEquals(List.of(1, 3), hits.stream().map(event -> event.getAsJsonObject("data").get("hop_ordinal").getAsInt()).toList());
|
||||
assertEquals("7.3125", hits.getLast().getAsJsonObject("data").get("defined_amount").getAsString());
|
||||
assertEquals(new BigDecimal("20").divide(BigDecimal.valueOf(3), java.math.MathContext.DECIMAL128).toPlainString(),
|
||||
hits.getLast().getAsJsonObject("data").get("defined_amount").getAsString());
|
||||
assertEquals(3, abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data").get("attempted_hops").getAsInt());
|
||||
assertEquals(2, abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data").get("accepted_hops").getAsInt());
|
||||
}
|
||||
@@ -170,12 +173,12 @@ class ChainLightningNativeTest {
|
||||
}).when(target).damage(anyDouble(), any(DamageSource.class));
|
||||
use(fixture, caster); drain(fixture, 2);
|
||||
assertEquals(2, events.size()); assertTrue(events.stream().allMatch(EntityDamageEvent::isCancelled));
|
||||
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 13 damage to First Target."::equals).count(), caster.messages::toString);
|
||||
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 9.75 damage to Second Target."::equals).count());
|
||||
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 10 damage to First Target."::equals).count(), caster.messages::toString);
|
||||
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 10 damage to Second Target."::equals).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void exactChainUsesNearestUuidTiesFiveTargetLimitDecayAndCancellationContinuation() {
|
||||
@Test void completeDeterministicChainPrecedesOneEqualFractionalDamageSharePerSelectedTarget() {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var caster = mock(Player.class);
|
||||
var world = mock(org.bukkit.World.class); UUID casterId = UUID.randomUUID(); when(caster.getUniqueId()).thenReturn(casterId);
|
||||
when(caster.getLocation()).thenReturn(new Location(world, 0, 64, 0)); when(caster.getEyeLocation()).thenReturn(new Location(world, 0, 65, 0));
|
||||
@@ -198,10 +201,69 @@ class ChainLightningNativeTest {
|
||||
(DamageSource) call.getArgument(1), amount)); return null;
|
||||
}).when(target).damage(anyDouble(), any(DamageSource.class)); }
|
||||
chain.activate(execution);
|
||||
assertEquals(targets.subList(0, 5).stream().map(LivingEntity::getUniqueId).toList(), List.copyOf(attempted.keySet()));
|
||||
assertArrayEquals(new double[]{13, 9.75, 7.3125, 5.484375, 4.11328125}, attempted.values().stream().mapToDouble(Double::doubleValue).toArray(), 1.0e-10);
|
||||
verify(targets.get(5), never()).damage(anyDouble(), any(DamageSource.class));
|
||||
verify(execution, times(4)).report(eq(AbilityReport.Type.DAMAGE), any(), any());
|
||||
assertEquals(targets.stream().map(LivingEntity::getUniqueId).toList(), List.copyOf(attempted.keySet()));
|
||||
assertArrayEquals(new double[]{20d / 6, 20d / 6, 20d / 6, 20d / 6, 20d / 6, 20d / 6},
|
||||
attempted.values().stream().mapToDouble(Double::doubleValue).toArray(), 1.0e-10);
|
||||
var captureAndDiscovery = new ArrayList<Object>(); captureAndDiscovery.add(execution); captureAndDiscovery.addAll(targets);
|
||||
var discovery = inOrder(captureAndDiscovery.toArray());
|
||||
discovery.verify(execution).character();
|
||||
for (var target : targets) discovery.verify(target).getNearbyEntities(6, 6, 6);
|
||||
discovery.verify(targets.getFirst()).damage(anyDouble(), any(DamageSource.class));
|
||||
verify(execution, times(5)).report(eq(AbilityReport.Type.DAMAGE), any(), any());
|
||||
}
|
||||
|
||||
@Test void newlyInvalidReservedShareIsNotRedistributedOrReplayed() {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var caster = mock(Player.class);
|
||||
var world = mock(org.bukkit.World.class); when(caster.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(caster.getLocation()).thenReturn(new Location(world, 0, 64, 0)); when(caster.getEyeLocation()).thenReturn(new Location(world, 0, 65, 0));
|
||||
var first = mock(LivingEntity.class); var invalidated = mock(LivingEntity.class); var third = mock(LivingEntity.class);
|
||||
locate(first, world, new UUID(0, 1), 10); locate(invalidated, world, new UUID(0, 2), 14); locate(third, world, new UUID(0, 3), 18);
|
||||
when(caster.hasLineOfSight(first)).thenReturn(true); when(first.hasLineOfSight(invalidated)).thenReturn(true);
|
||||
when(invalidated.hasLineOfSight(third)).thenReturn(true);
|
||||
when(first.getNearbyEntities(6, 6, 6)).thenReturn(List.of(invalidated));
|
||||
when(invalidated.getNearbyEntities(6, 6, 6)).thenReturn(List.of(third));
|
||||
var becameInvalid = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
when(invalidated.isDead()).thenAnswer(call -> becameInvalid.get());
|
||||
var active = new EquipmentSessions.Active(caster.getUniqueId(), UUID.randomUUID(), UUID.randomUUID());
|
||||
var execution = execution(caster, active, first);
|
||||
var chain = new ChainLightning(new ChainLightning.Settings(java.time.Duration.ofSeconds(18), 16, 6, 3), sessions, parties);
|
||||
when(execution.definition()).thenReturn(chain.definition());
|
||||
var applied = new LinkedHashMap<UUID, Double>();
|
||||
doAnswer(call -> {
|
||||
double amount = call.getArgument(0); applied.put(first.getUniqueId(), amount);
|
||||
chain.acceptedDamage(new EntityDamageEvent(first, EntityDamageEvent.DamageCause.LIGHTNING,
|
||||
(DamageSource) call.getArgument(1), amount)); becameInvalid.set(true); return null;
|
||||
}).when(first).damage(anyDouble(), any(DamageSource.class));
|
||||
doAnswer(call -> {
|
||||
double amount = call.getArgument(0); applied.put(third.getUniqueId(), amount);
|
||||
chain.acceptedDamage(new EntityDamageEvent(third, EntityDamageEvent.DamageCause.LIGHTNING,
|
||||
(DamageSource) call.getArgument(1), amount)); return null;
|
||||
}).when(third).damage(anyDouble(), any(DamageSource.class));
|
||||
chain.activate(execution);
|
||||
verify(invalidated, never()).damage(anyDouble(), any(DamageSource.class));
|
||||
assertEquals(List.of(first.getUniqueId(), third.getUniqueId()), List.copyOf(applied.keySet()));
|
||||
assertTrue(applied.values().stream().allMatch(value -> Math.abs(value - 20d / 3) < 1e-10));
|
||||
verify(execution).resolve(eq("hit"), argThat(evidence -> evidence.get("selected_chain_size").equals(3)
|
||||
&& evidence.get("attempted_hops").equals(2) && evidence.get("accepted_hops").equals(2)));
|
||||
}
|
||||
|
||||
@Test void nonFiniteNativeShareIsRefusedBeforeAnyTargetDamage() {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var caster = mock(Player.class);
|
||||
var world = mock(org.bukkit.World.class); when(caster.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(caster.getLocation()).thenReturn(new Location(world, 0, 64, 0)); when(caster.getEyeLocation()).thenReturn(new Location(world, 0, 65, 0));
|
||||
var target = mock(LivingEntity.class); locate(target, world, UUID.randomUUID(), 10); when(caster.hasLineOfSight(target)).thenReturn(true);
|
||||
var active = new EquipmentSessions.Active(caster.getUniqueId(), UUID.randomUUID(), UUID.randomUUID());
|
||||
var execution = execution(caster, active, target); var chain = new ChainLightning(ChainLightning.Settings.defaults(), sessions, parties);
|
||||
when(execution.definition()).thenReturn(chain.definition());
|
||||
var ordinary = execution.character(); var stats = new EnumMap<>(ordinary.stats()); stats.put(Stat.INT, BigInteger.TEN.pow(500));
|
||||
when(execution.character()).thenReturn(new CharacterRecord(ordinary.id(), ordinary.name(), ordinary.revision(), stats,
|
||||
ordinary.level(), ordinary.experience(), ordinary.unspentPoints(), ordinary.health(), ordinary.learned(), ordinary.loadout(),
|
||||
ordinary.cooldowns(), ordinary.restrictedKills(), false, null, null));
|
||||
chain.activate(execution);
|
||||
verify(target, never()).damage(anyDouble(), any(DamageSource.class));
|
||||
verify(execution).resolve(eq("failed"), argThat(evidence -> evidence.get("terminal_reason").equals("native_damage_not_finite")
|
||||
&& evidence.get("attempted_hops").equals(0) && evidence.get("accepted_hops").equals(0)));
|
||||
verify(execution, never()).acceptedDamage(any(), anyDouble(), anyDouble(), any(), anyMap());
|
||||
}
|
||||
|
||||
@Test void boundsVisibilityExclusionsAndEligibilityRaceStopInvalidStrikes() {
|
||||
@@ -244,47 +306,55 @@ class ChainLightningNativeTest {
|
||||
var definition = chain.definition(); assertEquals("chain_lightning", definition.id()); assertEquals(org.bukkit.Material.LIGHTNING_ROD, definition.icon());
|
||||
assertEquals(java.time.Duration.ofSeconds(18), definition.cooldown()); assertEquals(BigInteger.ONE, definition.pointCost());
|
||||
assertEquals(BigInteger.valueOf(20), definition.requirements().get(Stat.INT)); assertEquals(BigInteger.valueOf(12), definition.requirements().get(Stat.DEX));
|
||||
assertEquals(BigInteger.valueOf(13), ChainLightning.initialDamage(BigInteger.valueOf(20)));
|
||||
assertEquals(BigInteger.valueOf(7), ChainLightning.initialDamage(BigInteger.valueOf(9)));
|
||||
var huge = BigInteger.TEN.pow(500); assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(8)), ChainLightning.initialDamage(huge));
|
||||
assertEquals(BigInteger.valueOf(5), ChainLightning.intelligenceModifier(BigInteger.valueOf(20)));
|
||||
assertEquals(BigInteger.valueOf(20), ChainLightning.totalDefinedDamage(BigInteger.valueOf(20)));
|
||||
assertEquals(BigInteger.valueOf(40), ChainLightning.totalDefinedDamage(BigInteger.valueOf(30)));
|
||||
assertEquals(BigInteger.valueOf(-1), ChainLightning.intelligenceModifier(BigInteger.valueOf(9)));
|
||||
assertEquals(BigInteger.valueOf(-4), ChainLightning.totalDefinedDamage(BigInteger.valueOf(9)));
|
||||
var huge = BigInteger.TEN.pow(500);
|
||||
assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).multiply(BigInteger.valueOf(4)),
|
||||
ChainLightning.totalDefinedDamage(huge));
|
||||
assertEquals(new BigDecimal("4"), definition.parameters().get("int-modifier-damage-factor"));
|
||||
assertEquals(new BigDecimal("20"), definition.parameters().get("max-targets"));
|
||||
assertFalse(definition.parameters().containsKey("base-damage"));
|
||||
assertFalse(definition.parameters().containsKey("jump-multiplier"));
|
||||
}
|
||||
|
||||
@Test void pluginLoadsEverySettingAndAdminPublishesOnlyAfterDurableSave() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "chain-lightning-base-damage: 9\nchain-lightning-cooldown-seconds: 4\n"
|
||||
+ "chain-lightning-initial-range-blocks: 15\nchain-lightning-jump-range-blocks: 5\nchain-lightning-additional-targets: 2\nchain-lightning-jump-multiplier: 0.5\n");
|
||||
@Test void pluginLoadsEffectiveSettingsAndAdminPublishesOnlyAfterDurableSave() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "chain-lightning-base-damage: 999\nchain-lightning-cooldown-seconds: 4\n"
|
||||
+ "chain-lightning-initial-range-blocks: 15\nchain-lightning-jump-range-blocks: 5\nchain-lightning-additional-targets: 2\n"
|
||||
+ "chain-lightning-jump-multiplier: 0.5\nchain-lightning-target-int-step: 1\nchain-lightning-max-targets: 12\n");
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true); var registry = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
|
||||
var definition = registry.find("chain_lightning").orElseThrow(); assertEquals(new BigDecimal("9"), definition.parameters().get("base-damage"));
|
||||
var definition = registry.find("chain_lightning").orElseThrow();
|
||||
assertEquals(java.time.Duration.ofSeconds(4), definition.cooldown());
|
||||
assertEquals(0, new BigDecimal("15").compareTo(definition.parameters().get("initial-range")));
|
||||
assertEquals(0, new BigDecimal("5").compareTo(definition.parameters().get("jump-range")));
|
||||
assertEquals(new BigDecimal("2"), definition.parameters().get("additional-targets"));
|
||||
assertEquals(new BigDecimal("0.5"), definition.parameters().get("jump-multiplier"));
|
||||
assertEquals(new BigDecimal("12"), definition.parameters().get("max-targets"));
|
||||
assertFalse(definition.parameters().containsKey("base-damage")); assertFalse(definition.parameters().containsKey("jump-multiplier"));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
|
||||
assertTrue(fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "chain-lightning-"}).isEmpty());
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "7"});
|
||||
assertTrue(fixture.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-"}).isEmpty());
|
||||
fixture.command.execute(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-jump-range-blocks", "7"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
assertEquals(List.of("chain-lightning-additional-targets", "chain-lightning-base-damage", "chain-lightning-cooldown-seconds",
|
||||
"chain-lightning-initial-range-blocks", "chain-lightning-jump-multiplier", "chain-lightning-jump-range-blocks",
|
||||
"chain-lightning-max-targets", "chain-lightning-target-int-step"),
|
||||
fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "chain-lightning-"}));
|
||||
assertEquals(List.of("5", "6"), fixture.command.tabComplete(sender.port, "fantasystick",
|
||||
assertEquals(List.of("chain-lightning-cooldown-seconds", "chain-lightning-initial-range-blocks",
|
||||
"chain-lightning-jump-range-blocks", "chain-lightning-max-targets"),
|
||||
fixture.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-"}));
|
||||
assertEquals(List.of("5", "6"), fixture.command.tabComplete(sender.port, "fstickadmin",
|
||||
new String[]{"config", "chain-lightning-jump-range-blocks", ""}));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks"});
|
||||
fixture.command.execute(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-jump-range-blocks"});
|
||||
assertTrue(sender.messages.getLast().contains("=5"));
|
||||
for (String[] update : List.of(new String[]{"chain-lightning-base-damage", "10"},
|
||||
new String[]{"chain-lightning-cooldown-seconds", "3"}, new String[]{"chain-lightning-initial-range-blocks", "14"},
|
||||
new String[]{"chain-lightning-jump-range-blocks", "7"}, new String[]{"chain-lightning-additional-targets", "1"},
|
||||
new String[]{"chain-lightning-jump-multiplier", "0.25"})) {
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", update[0], update[1]});
|
||||
for (String[] update : List.of(new String[]{"chain-lightning-cooldown-seconds", "3"},
|
||||
new String[]{"chain-lightning-initial-range-blocks", "14"},
|
||||
new String[]{"chain-lightning-jump-range-blocks", "7"},
|
||||
new String[]{"chain-lightning-max-targets", "3"})) {
|
||||
fixture.command.execute(sender.port, "fstickadmin", new String[]{"config", update[0], update[1]});
|
||||
var response = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
}
|
||||
var configured = registry.find("chain_lightning").orElseThrow(); assertEquals(new BigDecimal("10"), configured.parameters().get("base-damage"));
|
||||
var configured = registry.find("chain_lightning").orElseThrow();
|
||||
assertEquals(java.time.Duration.ofSeconds(3), configured.cooldown());
|
||||
assertEquals(0, new BigDecimal("14").compareTo(configured.parameters().get("initial-range")));
|
||||
assertEquals(0, new BigDecimal("7").compareTo(configured.parameters().get("jump-range")));
|
||||
assertEquals(new BigDecimal("1"), configured.parameters().get("additional-targets"));
|
||||
assertEquals(new BigDecimal("0.25"), configured.parameters().get("jump-multiplier"));
|
||||
assertEquals(new BigDecimal("3"), configured.parameters().get("max-targets"));
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
@@ -297,22 +367,22 @@ class ChainLightningNativeTest {
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "chainLightningSettings"))
|
||||
.value("chain-lightning-jump-range-blocks"));
|
||||
assertTrue(sender.messages.getLast().contains("save failed"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "0"});
|
||||
fixture.command.execute(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-max-targets", "21"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "7", "extra"});
|
||||
fixture.command.execute(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-jump-range-blocks", "7", "extra"});
|
||||
assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
fixture.plugin.setEnabled(false); fixture.plugin.setEnabled(true);
|
||||
var reloaded = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
|
||||
var saved = reloaded.find("chain_lightning").orElseThrow();
|
||||
assertEquals(0, new BigDecimal("7").compareTo(saved.parameters().get("jump-range")));
|
||||
assertEquals(new BigDecimal("0.25"), saved.parameters().get("jump-multiplier"));
|
||||
assertEquals(new BigDecimal("3"), saved.parameters().get("max-targets"));
|
||||
assertEquals(java.time.Duration.ofSeconds(3), saved.cooldown());
|
||||
}
|
||||
var config = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))
|
||||
.filter(event -> event.getAsJsonObject("data").has("key")
|
||||
&& event.getAsJsonObject("data").get("key").getAsString().startsWith("chain-lightning-")).toList();
|
||||
assertEquals(7, config.size(), "One authorized read plus six acknowledged saved settings, no denied/invalid success");
|
||||
assertEquals(5, config.size(), "One authorized read plus four acknowledged effective settings, no denied/invalid success");
|
||||
}
|
||||
|
||||
private static void nativeDamage(FantasyStickLifecycleNativeTest.Fixture fixture, LivingEntity target, Map<UUID, Double> damage) {
|
||||
|
||||
@@ -1128,6 +1128,62 @@ class EquipmentSessionEventsNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void joinAfterCleanExitIsQuietAndPreservesPlayerAndReceiptState() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var a = f.actor; f.core.activate(a);
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.RESTORED,
|
||||
f.core.sessions.exit(a.player));
|
||||
var completed = f.core.state(a);
|
||||
assertNull(completed.pending()); assertNotNull(completed.lastExit());
|
||||
a.inventory.setItemInOffHand(new ItemStack(Material.EMERALD));
|
||||
var before = a.snapshot(); var cursor = a.cursor.clone(); var marker = a.marker();
|
||||
var record = f.core.characters.find(a.record.id()).join().orElseThrow();
|
||||
int notices = f.core.notices.size();
|
||||
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player,
|
||||
(net.kyori.adventure.text.Component) null));
|
||||
f.core.tick();
|
||||
|
||||
assertEquals(notices, f.core.notices.size(),
|
||||
"A matching completed RESTORED marker is clean arrival proof, not a recovery warning");
|
||||
assertArrayEquals(before, a.snapshot()); assertEquals(cursor, a.cursor);
|
||||
assertEquals(marker, a.marker()); assertEquals(completed, f.core.state(a));
|
||||
assertEquals(record, f.core.characters.find(a.record.id()).join().orElseThrow());
|
||||
assertTrue(f.core.sessions.active(a.id).isEmpty()); assertTrue(f.ticks.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void joinWarnsWithoutMutationForUnsafeMarkersAndGenuineRemnants() throws Exception {
|
||||
for (int scenario = 0; scenario < 4; scenario++) try (var f = new Fixture()) {
|
||||
var a = f.actor; f.core.activate(a);
|
||||
var genuineBox = a.inventory.getItem(9).clone();
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.RESTORED,
|
||||
f.core.sessions.exit(a.player));
|
||||
var completed = f.core.state(a);
|
||||
assertNull(completed.pending()); assertNotNull(completed.lastExit());
|
||||
a.inventory.setItemInOffHand(new ItemStack(Material.EMERALD));
|
||||
if (scenario == 0) a.pdc.set(new org.bukkit.NamespacedKey("fantasystick", "equipment_session"),
|
||||
org.bukkit.persistence.PersistentDataType.STRING, "malformed");
|
||||
if (scenario == 1) a.marker(java.util.UUID.randomUUID(), "RESTORED");
|
||||
if (scenario == 2) a.marker(completed.lastExit().sessionId(), "PACKED");
|
||||
if (scenario == 3) a.inventory.setItem(12, genuineBox);
|
||||
var before = a.snapshot(); var cursor = a.cursor.clone(); var marker = a.marker();
|
||||
var record = f.core.characters.find(a.record.id()).join().orElseThrow();
|
||||
int notices = f.core.notices.size();
|
||||
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player,
|
||||
(net.kyori.adventure.text.Component) null));
|
||||
f.core.tick();
|
||||
|
||||
assertEquals(notices + 1, f.core.notices.size());
|
||||
assertEquals("NATIVE_RECOVERY_REQUIRED", f.core.notices.getLast().code());
|
||||
assertArrayEquals(before, a.snapshot()); assertEquals(cursor, a.cursor);
|
||||
assertEquals(marker, a.marker()); assertEquals(completed, f.core.state(a));
|
||||
assertEquals(record, f.core.characters.find(a.record.id()).join().orElseThrow());
|
||||
assertTrue(f.core.sessions.active(a.id).isEmpty()); assertTrue(f.ticks.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void reservedWorldAndInventorySwapsRestoreBeforeExchangingOrdinaryGear() throws Exception {
|
||||
for (int route = 0; route < 3; route++) {
|
||||
try (var f = new Fixture()) {
|
||||
|
||||
@@ -776,10 +776,15 @@ class FantasyParticlesNativeTest {
|
||||
|
||||
private java.util.List<com.google.gson.JsonObject> preferenceRecords() throws Exception {
|
||||
var records = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
|
||||
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(path))
|
||||
if (!line.isBlank()) records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
try (var days = java.nio.file.Files.list(data.resolve("events"))) {
|
||||
for (var day : days.filter(path -> path.getFileName().toString()
|
||||
.matches("\\d{4}-\\d{2}-\\d{2}")).toList()) {
|
||||
try (var files = java.nio.file.Files.list(day)) {
|
||||
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(path))
|
||||
if (!line.isBlank()) records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
@@ -1325,7 +1325,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
private static void assertRestored(Fixture f, Actor actor, Actor viewer) {
|
||||
var spawn = f.packets(viewer.player, net.minecraft.network.protocol.game.ClientboundAddEntityPacket.class).getLast();
|
||||
assertTrue(f.packets(viewer.player, net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket.class).stream()
|
||||
.anyMatch(packet -> packet.getEntityIds().contains(spawn.getId())));
|
||||
.anyMatch(packet -> packet.entityIds().contains(spawn.getId())));
|
||||
var tab = f.packets(viewer.player, net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket.class).getLast();
|
||||
assertEquals(net.minecraft.network.chat.Component.literal("Ordinary tab"), tab.entries().getFirst().displayName());
|
||||
var attributes = f.packets(viewer.player, net.minecraft.network.protocol.game.ClientboundUpdateAttributesPacket.class).getLast();
|
||||
@@ -1432,9 +1432,9 @@ class FantasyStickLifecycleNativeTest {
|
||||
platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) {
|
||||
case "getServer" -> nativeServer;
|
||||
case "isPrimaryThread" -> Thread.currentThread() == main;
|
||||
case "getVersion" -> "Purpur 2618 (MC: 26.2)";
|
||||
case "getMinecraftVersion" -> "26.2";
|
||||
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT";
|
||||
case "getVersion" -> "Purpur 2651 (MC: 26.3)";
|
||||
case "getMinecraftVersion" -> "26.3";
|
||||
case "getBukkitVersion" -> "26.3-R0.1-SNAPSHOT";
|
||||
default -> call.callRealMethod();
|
||||
});
|
||||
var config = new io.papermc.paper.configuration.GlobalConfiguration();
|
||||
@@ -1468,6 +1468,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
case "callEvent" -> { dispatcher().accept((org.bukkit.event.Event) args[0]); yield null; }
|
||||
default -> throw new AssertionError("Unexpected manager call " + name);
|
||||
});
|
||||
when(nativeServer.getPluginManager()).thenReturn(manager);
|
||||
platform.when(org.bukkit.Bukkit::getPluginManager).thenReturn(manager);
|
||||
var scheduler = port(BukkitScheduler.class, (name, args) -> switch (name) {
|
||||
case "runTaskTimer" -> {
|
||||
if ("presentation-timer".equals(failRegistrationAt)) throw new IllegalStateException("Timer rejected");
|
||||
@@ -1497,8 +1499,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
case "getLogger" -> Logger.getLogger("FantasyStick lifecycle fixture");
|
||||
case "getWarningState" -> org.bukkit.Warning.WarningState.DEFAULT;
|
||||
case "getName" -> "Purpur";
|
||||
case "getVersion" -> "Purpur 2618 (MC: 26.2)";
|
||||
case "getMinecraftVersion" -> "26.2";
|
||||
case "getVersion" -> "Purpur 2651 (MC: 26.3)";
|
||||
case "getMinecraftVersion" -> "26.3";
|
||||
case "isPrimaryThread" -> Thread.currentThread() == main;
|
||||
case "getOnlinePlayers" -> List.copyOf(players.values());
|
||||
case "getWorlds" -> players.values().stream().map(org.bukkit.entity.Player::getWorld).distinct().toList();
|
||||
@@ -1703,6 +1705,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
final List<String> actionBars = new ArrayList<>();
|
||||
final List<org.bukkit.inventory.ItemStack> books = new ArrayList<>();
|
||||
final List<org.bukkit.inventory.Inventory> inventories = new ArrayList<>();
|
||||
final List<org.bukkit.inventory.InventoryView> inventoryViews = new ArrayList<>();
|
||||
org.bukkit.inventory.InventoryView openView;
|
||||
int inventoryCloses;
|
||||
final List<Integer> particles = new ArrayList<>();
|
||||
final List<Object[]> particleCalls = new ArrayList<>();
|
||||
@@ -1747,6 +1751,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
when(craftBoard.getHandle()).thenReturn(board);
|
||||
player = own(mock(org.bukkit.craftbukkit.entity.CraftPlayer.class, this));
|
||||
core.location.setWorld(world);
|
||||
when(nativePlayer.getBukkitEntity()).thenReturn(player);
|
||||
when(nativePlayer.getId()).thenReturn(42);
|
||||
when(nativePlayer.getUUID()).thenReturn(core.id);
|
||||
when(nativePlayer.getTabListDisplayName()).thenReturn(net.minecraft.network.chat.Component.literal("Ordinary tab"));
|
||||
@@ -1854,8 +1859,13 @@ class FantasyStickLifecycleNativeTest {
|
||||
yield null;
|
||||
}
|
||||
case "openBook" -> { books.add(((org.bukkit.inventory.ItemStack) args[0]).clone()); yield null; }
|
||||
case "openInventory" -> { inventories.add((org.bukkit.inventory.Inventory) args[0]); yield core.view; }
|
||||
case "closeInventory" -> { inventoryCloses++; yield null; }
|
||||
case "getOpenInventory" -> openView == null ? core.view : openView;
|
||||
case "openInventory" -> {
|
||||
if (args[0] instanceof org.bukkit.inventory.Inventory inventory) { inventories.add(inventory); openView=null; yield core.view; }
|
||||
var opened = (org.bukkit.inventory.InventoryView) args[0]; inventoryViews.add(opened);
|
||||
inventories.add(opened.getTopInventory()); openView=opened; yield null;
|
||||
}
|
||||
case "closeInventory" -> { inventoryCloses++; openView=null; yield null; }
|
||||
case "spawnParticle" -> { particleCalls.add(args.clone()); particles.add((Integer) args[2]); yield null; }
|
||||
case "playSound" -> { if (args.length > 1 && args[1] instanceof org.bukkit.Sound sound) sounds.add(sound); yield null; }
|
||||
case "showTitle" -> { titles.add((net.kyori.adventure.title.Title)args[0]); yield null; }
|
||||
|
||||
@@ -73,6 +73,8 @@ class FrostShardNativeTest {
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(target.getPersistentDataContainer()).thenReturn(new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
|
||||
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
|
||||
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 10, 65.6, 0));
|
||||
when(target.addPotionEffect(any())).thenReturn(true);
|
||||
var vanilla = new org.bukkit.event.entity.EntityDamageByEntityEvent(projectile, target,
|
||||
@@ -89,7 +91,7 @@ class FrostShardNativeTest {
|
||||
var hit = new org.bukkit.event.entity.ProjectileHitEvent(projectile, target);
|
||||
f.dispatcher.accept(hit);
|
||||
assertFalse(hit.isCancelled(), "Eligible hostile Frost Shard hit was unexpectedly cancelled");
|
||||
assertEquals(1, damages.size()); assertEquals(5.0, damages.getFirst().getDamage());
|
||||
assertEquals(1, damages.size()); assertEquals(20.0, damages.getFirst().getDamage());
|
||||
var effect = org.mockito.ArgumentCaptor.forClass(org.bukkit.potion.PotionEffect.class);
|
||||
verify(target).addPotionEffect(effect.capture());
|
||||
assertEquals(org.bukkit.potion.PotionEffectType.SLOWNESS, effect.getValue().getType());
|
||||
@@ -121,8 +123,8 @@ class FrostShardNativeTest {
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals(resolution.get("projectile_id").getAsString(), damage.get("projectile_id").getAsString());
|
||||
assertTrue(damage.getAsJsonObject("impact_location").has("world_id"));
|
||||
assertEquals(5, damage.get("requested_amount").getAsDouble());
|
||||
assertEquals(5, damage.get("actual_amount").getAsDouble());
|
||||
assertEquals(20, damage.get("requested_amount").getAsDouble());
|
||||
assertEquals(20, damage.get("actual_amount").getAsDouble());
|
||||
assertTrue(damage.getAsJsonObject("target_location").has("world_id"));
|
||||
assertTrue(damage.has("direct_entity_id"));
|
||||
}
|
||||
@@ -150,8 +152,23 @@ class FrostShardNativeTest {
|
||||
frost.close();
|
||||
}
|
||||
|
||||
@Test void pendingCastUsesItsCapturedDefinitionEvenIfConfigurationChangesBeforeLaunch() throws Exception {
|
||||
static java.util.stream.Stream<org.junit.jupiter.params.provider.Arguments> pendingDamageCases() {
|
||||
return java.util.stream.Stream.of(
|
||||
org.junit.jupiter.params.provider.Arguments.of(BigInteger.ZERO, -4.0),
|
||||
org.junit.jupiter.params.provider.Arguments.of(BigInteger.valueOf(12), 20.0),
|
||||
org.junit.jupiter.params.provider.Arguments.of(BigInteger.valueOf(20), 36.0),
|
||||
org.junit.jupiter.params.provider.Arguments.of(BigInteger.TEN.pow(500), Double.MAX_VALUE));
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.MethodSource("pendingDamageCases")
|
||||
void pendingCastUsesItsCapturedDefinitionEvenIfConfigurationChangesBeforeLaunch(BigInteger intelligence, double expectedDamage) throws Exception {
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var base = actor.core.record; var stats = new java.util.EnumMap<>(base.stats());
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.INT, intelligence);
|
||||
var record = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), base.name(), base.revision(), stats,
|
||||
base.level(), base.experience(), base.unspentPoints(), base.health(), base.learned(), base.loadout(), base.cooldowns(),
|
||||
base.restrictedKills(), false, null, null);
|
||||
var particles = mock(games.dmg.fantasystick.presentation.ParticleDelivery.class);
|
||||
var frost = new games.dmg.fantasystick.ability.FrostShard(
|
||||
games.dmg.fantasystick.ability.FrostShard.Settings.defaults(), particles);
|
||||
@@ -164,7 +181,7 @@ class FrostShardNativeTest {
|
||||
when(execution.stillCurrent()).thenReturn(true);
|
||||
when(execution.definition()).thenReturn(captured);
|
||||
when(execution.input()).thenReturn(input);
|
||||
when(execution.character()).thenReturn(actor.core.record);
|
||||
when(execution.character()).thenReturn(record);
|
||||
when(input.player()).thenReturn(actor.player);
|
||||
var eye = new org.bukkit.Location(actor.world, 0, 65.6, 0);
|
||||
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
|
||||
@@ -181,8 +198,8 @@ class FrostShardNativeTest {
|
||||
frost.hit(new org.bukkit.event.entity.ProjectileHitEvent(arrow, target));
|
||||
var amount = org.mockito.ArgumentCaptor.forClass(double.class);
|
||||
verify(execution).damage(eq(target), amount.capture(), any(org.bukkit.damage.DamageSource.class));
|
||||
assertEquals(games.dmg.fantasystick.ability.FrostShard.damage(actor.core.record.stats()
|
||||
.get(games.dmg.fantasystick.progression.Stat.INT)).doubleValue(), amount.getValue());
|
||||
assertEquals(expectedDamage, amount.getValue());
|
||||
assertTrue(Double.isFinite(amount.getValue()), "uncapped INT must convert to finite native damage");
|
||||
frost.close();
|
||||
}
|
||||
|
||||
@@ -652,12 +669,18 @@ class FrostShardNativeTest {
|
||||
.contains("Unavailable:requirements"));
|
||||
assertThrows(AssertionError.class, () -> abilityButton(unavailable, "[Learn]", "frost_shard"));
|
||||
}
|
||||
assertEquals(BigInteger.valueOf(4), games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.TEN));
|
||||
assertEquals(BigInteger.valueOf(5), games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.valueOf(12)));
|
||||
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(4)),
|
||||
assertEquals(BigInteger.valueOf(16), games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.TEN));
|
||||
assertEquals(BigInteger.valueOf(20), games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.valueOf(12)));
|
||||
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(4)).multiply(BigInteger.valueOf(4)),
|
||||
games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.TEN.pow(500)));
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.CsvSource({"0,-4", "1,-4", "9,12", "10,16", "11,16", "12,20", "13,20", "20,36", "21,36"})
|
||||
void fourfoldDamagePreservesMathematicalFloor(int intelligence, int expected) {
|
||||
assertEquals(BigInteger.valueOf(expected), games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.valueOf(intelligence)));
|
||||
}
|
||||
|
||||
@Test void invalidPackagedFrostSettingsFailBeforeInstallingGameplay() throws Exception {
|
||||
for (String configuration : java.util.List.of("frost-shard-base-damage: -1\n",
|
||||
"frost-shard-cooldown-seconds: 31536001\n", "frost-shard-range-blocks: 0\n",
|
||||
@@ -787,7 +810,9 @@ class FrostShardNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acknowledgedUpdatesAffectFutureCastsButNotAnActiveProjectileSnapshot() throws Exception {
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
|
||||
void acknowledgedUpdatesAffectFutureCastsButNotPendingOrInFlightSnapshots(boolean pending) throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "frost-shard-cooldown-seconds: 0\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
@@ -795,23 +820,39 @@ class FrostShardNativeTest {
|
||||
f.players.put(a.core.id, a.player); f.activate(a); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var oldProjectile = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
|
||||
var newProjectile = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
|
||||
prepareLaunches(f, a, oldProjectile, newProjectile); cast(f, a);
|
||||
|
||||
prepareLaunches(f, a, oldProjectile, newProjectile);
|
||||
Runnable pendingLaunch = null;
|
||||
if (pending) {
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player,
|
||||
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, a.core.inventory.getItem(2), null,
|
||||
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
pendingLaunch = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(pendingLaunch);
|
||||
verify(a.player, never()).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
|
||||
} else cast(f, a);
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var deadlines = repository.load(a.core.record.id()).orElseThrow().cooldowns();
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-shard-base-damage", "9"});
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", "frost-shard-base-damage", "9"});
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
assertEquals(deadlines, repository.load(a.core.record.id()).orElseThrow().cooldowns());
|
||||
if (pendingLaunch != null) pendingLaunch.run();
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE); when(target.getWorld()).thenReturn(a.world);
|
||||
when(target.getName()).thenReturn("Snapshot hostile");
|
||||
when(target.getPersistentDataContainer()).thenReturn(new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
|
||||
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
|
||||
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 2, 65.6, 0)); when(target.addPotionEffect(any())).thenReturn(true);
|
||||
var damages = new java.util.ArrayList<Double>();
|
||||
doAnswer(call -> {
|
||||
damages.add(call.getArgument(0)); var source = call.getArgument(1, org.bukkit.damage.DamageSource.class);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(source.getDirectEntity(), target,
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(damages.size() == 1 ? oldProjectile : newProjectile, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source, (Double) call.getArgument(0)));
|
||||
return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(oldProjectile, target));
|
||||
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(newProjectile, target));
|
||||
assertEquals(java.util.List.of(5.0, 10.0), damages);
|
||||
assertEquals(java.util.List.of(20.0, 40.0), damages);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,25 +47,32 @@ class FrostShardRangeNativeTest {
|
||||
|
||||
static java.util.stream.Stream<org.junit.jupiter.params.provider.Arguments> distantCases() {
|
||||
return java.util.stream.Stream.of(
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "", "hit"),
|
||||
org.junit.jupiter.params.provider.Arguments.of(48.0, "", "hit"),
|
||||
org.junit.jupiter.params.provider.Arguments.of(48.01, "", "miss"),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "retained", "expired"),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "snapshot", "expired"),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "cancelled", "cancelled"),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "obstructed", "miss"),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "empty", "expired"));
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "", "hit", 12, 4, 20),
|
||||
org.junit.jupiter.params.provider.Arguments.of(48.0, "", "hit", 12, 4, 20),
|
||||
org.junit.jupiter.params.provider.Arguments.of(48.01, "", "miss", 12, 4, 20),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "retained", "expired", 12, 4, 20),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "snapshot", "expired", 12, 4, 20),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "cancelled", "cancelled", 12, 4, 20),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "obstructed", "miss", 12, 4, 20),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "empty", "expired", 12, 4, 20),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "", "hit", 20, 4, 36),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "", "hit", 21, 4, 36),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "", "hit", 12, 9, 40),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "mitigated", "hit", 20, 9, 56),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "zero", "miss", 12, 4, 20));
|
||||
}
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.MethodSource("distantCases")
|
||||
void bookEquippedRealArrowFlightNativeDistantImpactSlowAndTruthfulJsonl(double distance, String mode, String outcome) throws Exception {
|
||||
void bookEquippedRealArrowFlightNativeDistantImpactSlowAndTruthfulJsonl(double distance, String mode, String outcome,
|
||||
int intelligence, int base, double requestedDamage) throws Exception {
|
||||
boolean retained = mode.equals("retained") || mode.equals("snapshot");
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "adventure-daily-site-count: 0\n"
|
||||
String retainedConfiguration = "adventure-daily-site-count: 0\nfrost-shard-base-damage: " + base + "\n"
|
||||
+ (retained ? "frost-shard-range-blocks: 32\n" : "")
|
||||
+ (mode.equals("snapshot") ? "frost-shard-cooldown-seconds: 0\n" : ""));
|
||||
+ (mode.equals("snapshot") ? "frost-shard-cooldown-seconds: 0\n" : "");
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), retainedConfiguration);
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data); seed(actor);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data); seed(actor, intelligence);
|
||||
f.players.put(actor.core.id, actor.player); f.activate(actor);
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, actor);
|
||||
String book = PartyBookNativeTest.text(actor).replaceAll("\\s+", "");
|
||||
@@ -121,6 +128,8 @@ class FrostShardRangeNativeTest {
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(source.getDirectEntity(), target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source, (Double) call.getArgument(0));
|
||||
damageEvents.add(event); if (mode.equals("cancelled")) event.setCancelled(true);
|
||||
if (mode.equals("mitigated")) event.setDamage(event.getDamage() / 2);
|
||||
if (mode.equals("zero")) event.setDamage(0);
|
||||
f.dispatcher.accept(event); return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
nativeArrow.delivery = result -> {
|
||||
@@ -162,11 +171,14 @@ class FrostShardRangeNativeTest {
|
||||
}
|
||||
assertTrue(nativeArrow.isRemoved(), "impact or captured range expiry must physically discard owned Arrow");
|
||||
boolean accepted = outcome.equals("hit");
|
||||
assertEquals(accepted || mode.equals("cancelled") ? 1 : 0, damageEvents.size(), "only actual in-range native collision may attempt damage");
|
||||
assertEquals(accepted || mode.equals("cancelled") || mode.equals("zero") ? 1 : 0, damageEvents.size(),
|
||||
"only actual in-range native collision may attempt damage");
|
||||
double finalDamage = mode.equals("mitigated") ? requestedDamage / 2 : requestedDamage;
|
||||
if (!damageEvents.isEmpty()) verify(target, times(1)).damage(eq(requestedDamage), any(org.bukkit.damage.DamageSource.class));
|
||||
if (accepted) {
|
||||
assertNotNull(nativeArrow.hit); assertEquals(HitResult.Type.ENTITY, nativeArrow.hit.getType());
|
||||
assertEquals(distance, nativeArrow.position().distanceTo(origin), 1e-9);
|
||||
assertEquals(5, damageEvents.getFirst().getFinalDamage()); assertFalse(damageEvents.getFirst().isCancelled());
|
||||
assertEquals(finalDamage, damageEvents.getFirst().getFinalDamage()); assertFalse(damageEvents.getFirst().isCancelled());
|
||||
assertSame(actor.player, damageEvents.getFirst().getDamageSource().getCausingEntity());
|
||||
assertSame(bukkit, damageEvents.getFirst().getDamageSource().getDirectEntity());
|
||||
assertNotNull(slow.get()); assertEquals(40, slow.get().getDuration()); assertEquals(0, slow.get().getAmplifier());
|
||||
@@ -187,12 +199,18 @@ class FrostShardRangeNativeTest {
|
||||
var capture = rows("ability.cast").getFirst().getAsJsonObject("data");
|
||||
assertEquals(retained ? "32" : "48", capture.getAsJsonObject("captured_settings").get("range").getAsString());
|
||||
assertTrue(book.contains((retained ? "32" : "48") + "-blocktravellimit"), "real book must explain captured effective reach");
|
||||
assertEquals(Integer.toString(intelligence), capture.get("captured_int").getAsString());
|
||||
assertEquals(Integer.toString(base), capture.getAsJsonObject("captured_settings").get("base-damage").getAsString());
|
||||
assertTrue(book.contains("4*(" + base + "+INTmodifier)"), "book must expose the effective full fourfold formula");
|
||||
if (!mode.equals("snapshot")) assertEquals(retainedConfiguration, java.nio.file.Files.readString(data.resolve("config.yml")),
|
||||
"loading and casting must not rewrite retained configured bases");
|
||||
if (accepted) {
|
||||
var damage = rows("combat.damage").getFirst().getAsJsonObject("data");
|
||||
assertEquals(capture.get("execution_id"), damage.get("execution_id"));
|
||||
assertEquals(bukkit.getUniqueId().toString(), damage.get("projectile_id").getAsString());
|
||||
assertEquals(nativeArrow.getX(), damage.getAsJsonObject("impact_location").get("x").getAsDouble(), 1e-9);
|
||||
assertEquals(5, damage.get("actual_amount").getAsDouble());
|
||||
assertEquals(requestedDamage, damage.get("requested_amount").getAsDouble());
|
||||
assertEquals(finalDamage, damage.get("actual_amount").getAsDouble());
|
||||
}
|
||||
if (mode.equals("snapshot")) {
|
||||
var next = new CollisionArrow(actor.world.getHandle());
|
||||
@@ -282,10 +300,10 @@ class FrostShardRangeNativeTest {
|
||||
throw new AssertionError("bounded actual native flight cannot reach fixture collision face");
|
||||
} finally { oracle.discard(); }
|
||||
}
|
||||
private void seed(FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
private void seed(FantasyStickLifecycleNativeTest.Actor actor, int intelligence) throws Exception {
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data); var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
var stats = new java.util.EnumMap<>(old.stats());
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.INT, java.math.BigInteger.valueOf(12));
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.INT, java.math.BigInteger.valueOf(intelligence));
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.WIS, java.math.BigInteger.TEN);
|
||||
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE), stats,
|
||||
old.level(), old.experience(), java.math.BigInteger.valueOf(40), old.health(), old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
|
||||
@@ -204,7 +204,7 @@ class ManualCraftDeliveryNativeTest {
|
||||
assertEquals(ManualCraftDelivery.Result.DELIVERED, ManualCraftDelivery.commit(plan, batch, fixture.view));
|
||||
assertEquals(org.bukkit.Material.DIAMOND, fixture.view.getCursor().getType());
|
||||
assertEquals(record.id(), new CharacterSheets().identify(
|
||||
org.bukkit.craftbukkit.inventory.CraftItemStack.asCraftMirror(fixture.menu.getCarried())).orElseThrow());
|
||||
org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitMirror(fixture.menu.getCarried())).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -314,7 +314,7 @@ class ManualCraftPlanNativeTest {
|
||||
return human;
|
||||
}
|
||||
@Override public InventoryType getType() { return grid.getType(); }
|
||||
@Override public ItemStack getCursor() { return CraftItemStack.asCraftMirror(activeMenu.getCarried()); }
|
||||
@Override public ItemStack getCursor() { return CraftItemStack.asBukkitMirror(activeMenu.getCarried()); }
|
||||
@Override public void setCursor(ItemStack item) { activeMenu.setCarried(CraftItemStack.asNMSCopy(item)); }
|
||||
@Override public String getTitle() { return "Crafting fixture"; }
|
||||
@Override public String getOriginalTitle() { return getTitle(); }
|
||||
|
||||
@@ -15,7 +15,7 @@ class NativeCompatibilityTest {
|
||||
try (var jar = new JarFile(Path.of(System.getProperty("distribution.jar")).toFile());
|
||||
var descriptor = jar.getInputStream(jar.getJarEntry("plugin.yml"))) {
|
||||
var metadata = new PluginDescriptionFile(descriptor);
|
||||
assertEquals("26.2", metadata.getAPIVersion());
|
||||
assertEquals("26.3", metadata.getAPIVersion());
|
||||
assertEquals(ApiVersion.CURRENT, ApiVersion.getOrCreateVersion(metadata.getAPIVersion()));
|
||||
assertEquals("games.dmg.fantasystick.FantasyStickPlugin", metadata.getMain());
|
||||
assertTrue(org.bukkit.plugin.java.JavaPlugin.class.isAssignableFrom(FantasyStickPlugin.class));
|
||||
|
||||
@@ -284,8 +284,8 @@ class NativePresentationPacketsTest {
|
||||
assertEquals(Component.literal("Old"), originalMetadata.packedItems().stream()
|
||||
.filter(value -> value.id() == changed.getFirst().id()).findFirst().orElseThrow().value());
|
||||
var removal = wire(net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket.STREAM_CODEC, label.remove());
|
||||
assertArrayEquals(new int[] {id}, removal.getEntityIds().toIntArray());
|
||||
assertArrayEquals(new int[] {id}, label.remove().getEntityIds().toIntArray());
|
||||
assertArrayEquals(new int[] {id}, removal.entityIds().toIntArray());
|
||||
assertArrayEquals(new int[] {id}, label.remove().entityIds().toIntArray());
|
||||
assertEquals(id, label.id());
|
||||
assertEquals(uuid, label.uuid());
|
||||
assertEquals(allocated, player.world.chunks.checks, "Updates and removals never allocate replacement IDs");
|
||||
@@ -392,7 +392,7 @@ class NativePresentationPacketsTest {
|
||||
private static final class ReadChunks extends net.minecraft.server.level.ServerChunkCache {
|
||||
private int checks;
|
||||
private boolean rejectNext;
|
||||
private ReadChunks() { super(null, null, null, null, null, null, 0, 0, false, null, null, null); }
|
||||
private ReadChunks() { super(null, null, null, null, null, null, 0, 0, false, null, null); }
|
||||
@Override public boolean hasEntityWithId(int id) {
|
||||
checks++;
|
||||
boolean reject = rejectNext;
|
||||
|
||||
@@ -40,12 +40,12 @@ final class NativeRuntime {
|
||||
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);
|
||||
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLD), tags);
|
||||
RegistryAccess.Frozen world = RegistryDataLoader.load(resources, lookups,
|
||||
RegistryDataLoader.WORLD_REGISTRIES, Util.backgroundExecutor()).join();
|
||||
layers = layers.replaceFrom(RegistryLayer.WORLD, world);
|
||||
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
|
||||
Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(),
|
||||
Stream.concat(lookups.stream(), world.listRegistries()).toList(),
|
||||
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
|
||||
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
|
||||
Class.forName("org.bukkit.Registry"); // Initialize the fixed API registry before loading data packs.
|
||||
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.party.CharacterPartyDirectory;
|
||||
import games.dmg.fantasystick.party.PartyManagement;
|
||||
import games.dmg.fantasystick.party.PartyRegistrar;
|
||||
import games.dmg.fantasystick.party.PartyRegistrarController;
|
||||
import games.dmg.fantasystick.party.PartyRegistrarDirectory;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import net.minecraft.world.inventory.AnvilMenu;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Villager;
|
||||
import org.bukkit.event.inventory.ClickType;
|
||||
import org.bukkit.event.inventory.InventoryAction;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.inventory.InventoryType;
|
||||
import org.bukkit.event.player.PlayerInteractEntityEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.MenuType;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import org.bukkit.inventory.view.AnvilView;
|
||||
import org.bukkit.persistence.PersistentDataContainer;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class PartyRegistrarNameSubmissionNativeTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredNativeResultIsFreeAndCreatesOnePartyAtZeroVanillaExperience() throws Exception {
|
||||
try (var path = new RegistrarPath(data.resolve("success"), BigInteger.valueOf(20))) {
|
||||
var view = path.openName();
|
||||
int paperBefore = paperCount(path.actor.core.inventory);
|
||||
path.actor.core.inventory.setItem(7, new ItemStack(Material.DIAMOND, 3));
|
||||
var unrelatedBefore = path.actor.core.inventory.getItem(7).clone();
|
||||
var inputBefore = view.getTopInventory().getItem(0).clone();
|
||||
|
||||
var result = path.prepare(view, " Cafe\u0301 Heroes ");
|
||||
assertEquals(0, view.getRepairCost());
|
||||
assertEquals(0, view.getRepairItemCountCost());
|
||||
assertTrue(view.canBypassCost());
|
||||
assertNotNull(result, "The registered native prepare path must make the result available");
|
||||
assertEquals(Material.PAPER, result.getType());
|
||||
assertNotNull(view.getTopInventory().getLocation());
|
||||
var nativeMenu = assertInstanceOf(AnvilMenu.class,
|
||||
((org.bukkit.craftbukkit.inventory.CraftInventoryView<?, ?>) view).getHandle());
|
||||
assertFalse(nativeMenu.checkReachable, "The virtual text-entry menu must not require a physical anvil block");
|
||||
|
||||
var priorCancellation = path.submit(view, true);
|
||||
assertTrue(priorCancellation.isCancelled());
|
||||
path.assertUnchanged(BigInteger.valueOf(20));
|
||||
var first = path.submit(view);
|
||||
var repeated = path.submit(view);
|
||||
assertTrue(first.isCancelled(), "The plugin owns result submission instead of native taking");
|
||||
assertTrue(repeated.isCancelled());
|
||||
assertTrue(path.actor.messages.stream().anyMatch(message -> message.contains("already in progress")),
|
||||
path.actor.messages::toString);
|
||||
|
||||
path.await(() -> path.parties.partyOf(path.actor.core.record.id()).isPresent()
|
||||
&& path.actor.messages.contains("Registered party."));
|
||||
var party = path.parties.partyOf(path.actor.core.record.id()).orElseThrow();
|
||||
assertEquals("Café Heroes", party.displayName());
|
||||
assertEquals(path.actor.core.record.id(), party.leader());
|
||||
assertEquals(1, path.parties.all().size());
|
||||
assertEquals(BigInteger.TEN, path.characters.find(path.actor.core.record.id()).get().orElseThrow().coins());
|
||||
assertEquals(0, path.actor.player.getLevel());
|
||||
verify(path.actor.player, never()).setLevel(anyInt());
|
||||
assertEquals(paperBefore, paperCount(path.actor.core.inventory));
|
||||
assertEquals(unrelatedBefore, path.actor.core.inventory.getItem(7));
|
||||
assertTrue(path.actor.core.cursor == null || path.actor.core.cursor.getType().isAir());
|
||||
assertEquals(inputBefore, view.getTopInventory().getItem(0));
|
||||
assertEquals(result, view.getTopInventory().getItem(2));
|
||||
assertEquals(Material.PAPER, view.getTopInventory().getItem(2).getType(),
|
||||
"The cancelled plugin control remains in the view until the view closes");
|
||||
assertTrue(path.actor.inventories.getLast().getItem(4).getItemMeta().getDisplayName().contains("Café Heroes"));
|
||||
path.fixture.dispatcher.accept(new org.bukkit.event.inventory.InventoryCloseEvent(view));
|
||||
assertNull(view.getTopInventory().getItem(0)); assertNull(view.getTopInventory().getItem(2));
|
||||
nativeMenu.removed(path.actor.nativePlayer);
|
||||
assertEquals(0, path.actor.nativePlayer.getInventory().getContents().stream()
|
||||
.filter(item -> item.is(net.minecraft.world.item.Items.PAPER)).mapToInt(net.minecraft.world.item.ItemStack::getCount).sum());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void invalidDuplicateInsufficientDistantAndStaleSubmissionsRefuseWithoutCharging() throws Exception {
|
||||
try (var path = new RegistrarPath(data.resolve("invalid"), BigInteger.valueOf(20))) {
|
||||
var view = path.openName(); view.getTopInventory().setItem(2, null); path.submit(view);
|
||||
assertTrue(path.actor.messages.stream().anyMatch(message -> message.contains("result is unavailable")), path.actor.messages::toString);
|
||||
path.prepare(view, " "); path.submit(view);
|
||||
path.prepare(view, "x".repeat(33)); path.submit(view);
|
||||
assertEquals(2, path.actor.messages.stream().filter(message -> message.contains("invalid name")).count(), path.actor.messages::toString);
|
||||
path.assertUnchanged(BigInteger.valueOf(20));
|
||||
}
|
||||
try (var path = new RegistrarPath(data.resolve("duplicate"), BigInteger.valueOf(20))) {
|
||||
path.parties.create(UUID.randomUUID(), UUID.randomUUID(), "Café Heroes").get();
|
||||
var view = path.openName(); path.prepare(view, " cafe\u0301 heroes "); path.submit(view);
|
||||
assertTrue(path.actor.messages.stream().anyMatch(message -> message.contains("name taken")), path.actor.messages::toString);
|
||||
assertTrue(path.parties.partyOf(path.actor.core.record.id()).isEmpty());
|
||||
assertEquals(BigInteger.valueOf(20), path.characters.find(path.actor.core.record.id()).get().orElseThrow().coins());
|
||||
}
|
||||
try (var path = new RegistrarPath(data.resolve("insufficient"), BigInteger.valueOf(3))) {
|
||||
var view = path.openName(); path.prepare(view, "Poor Heroes"); path.submit(view);
|
||||
path.await(() -> path.actor.messages.stream().anyMatch(message -> message.contains("insufficient")));
|
||||
path.assertUnchanged(BigInteger.valueOf(3));
|
||||
}
|
||||
try (var path = new RegistrarPath(data.resolve("distant"), BigInteger.valueOf(20))) {
|
||||
var view = path.openName(); path.prepare(view, "Far Heroes");
|
||||
path.actor.core.location.setX(path.actor.core.location.getX() + 100);
|
||||
path.submit(view);
|
||||
assertTrue(path.actor.messages.stream().anyMatch(message -> message.contains("too far from the Party Registrar")),
|
||||
path.actor.messages::toString);
|
||||
path.assertUnchanged(BigInteger.valueOf(20));
|
||||
}
|
||||
try (var path = new RegistrarPath(data.resolve("stale"), BigInteger.valueOf(20))) {
|
||||
var stale = path.openName(); path.prepare(stale, "Stale Heroes");
|
||||
path.actor.core.inventory.setItemInOffHand(null); path.fixture.changed(path.actor);
|
||||
PartyBookNativeTest.drain(path.fixture); path.fixture.awaitCharacterAndReceipt(path.actor);
|
||||
var current = new YamlCharacterRepository(path.directory).load(path.actor.core.record.id()).orElseThrow();
|
||||
path.actor.core.inventory.setItemInOffHand(new CharacterSheets().create(current)); path.fixture.activate(path.actor);
|
||||
path.submit(stale);
|
||||
assertTrue(path.actor.messages.stream().anyMatch(message -> message.contains("character or session changed")),
|
||||
path.actor.messages::toString);
|
||||
path.assertUnchanged(BigInteger.valueOf(20));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedPartySaveRefundsTheCoinAndARegisteredRetrySucceedsOnce() throws Exception {
|
||||
try (var path = new RegistrarPath(data.resolve("save-failure"), BigInteger.valueOf(20))) {
|
||||
var partyFile = path.directory.resolve("parties.yml");
|
||||
assertTrue(Files.notExists(partyFile)); Files.createDirectory(partyFile);
|
||||
var view = path.openName(); path.prepare(view, "Recovered Heroes"); path.submit(view);
|
||||
path.await(() -> path.actor.messages.stream().anyMatch(message -> message.contains("failed")));
|
||||
path.assertUnchanged(BigInteger.valueOf(20));
|
||||
assertTrue(Files.isDirectory(partyFile)); Files.delete(partyFile);
|
||||
|
||||
path.reenter(); view = path.openName();
|
||||
path.prepare(view, "Recovered Heroes"); path.submit(view);
|
||||
path.await(() -> path.parties.partyOf(path.actor.core.record.id()).isPresent()
|
||||
&& path.actor.messages.contains("Registered party."));
|
||||
assertEquals(1, path.parties.all().size());
|
||||
assertEquals(BigInteger.TEN, path.characters.find(path.actor.core.record.id()).get().orElseThrow().coins());
|
||||
}
|
||||
try (var path = new RegistrarPath(data.resolve("coin-failure"), BigInteger.valueOf(20))) {
|
||||
var characterFile = path.directory.resolve("characters").resolve(path.actor.core.record.id() + ".yml");
|
||||
var backup = path.directory.resolve("character-backup.yml");
|
||||
Files.move(characterFile, backup); Files.createDirectory(characterFile);
|
||||
var view = path.openName(); path.prepare(view, "Coin Recovery"); path.submit(view);
|
||||
path.await(() -> path.actor.messages.stream().anyMatch(message -> message.contains("error")));
|
||||
assertTrue(path.parties.partyOf(path.actor.core.record.id()).isEmpty());
|
||||
assertTrue(Files.isDirectory(characterFile)); Files.delete(characterFile); Files.move(backup, characterFile);
|
||||
path.assertUnchanged(BigInteger.valueOf(20));
|
||||
|
||||
path.prepare(view, "Coin Recovery Retry"); path.submit(view);
|
||||
path.await(() -> path.parties.partyOf(path.actor.core.record.id()).isPresent()
|
||||
&& path.actor.messages.contains("Registered party."));
|
||||
assertEquals(1, path.parties.all().size());
|
||||
assertEquals(BigInteger.TEN, path.characters.find(path.actor.core.record.id()).get().orElseThrow().coins());
|
||||
}
|
||||
}
|
||||
|
||||
private static int paperCount(PlayerInventory inventory) {
|
||||
int count = 0;
|
||||
for (var item : inventory.getContents()) if (item != null && item.getType() == Material.PAPER) count += item.getAmount();
|
||||
return count;
|
||||
}
|
||||
|
||||
private static final class RegistrarPath implements AutoCloseable {
|
||||
final Path directory;
|
||||
final FantasyStickLifecycleNativeTest.Fixture fixture;
|
||||
final FantasyStickLifecycleNativeTest.Actor actor;
|
||||
final PartyRegistrar registrar;
|
||||
final CharacterPartyDirectory parties;
|
||||
final CharacterStorage characters;
|
||||
final Villager villager;
|
||||
|
||||
RegistrarPath(Path directory, BigInteger coins) throws Exception {
|
||||
this.directory = directory;
|
||||
fixture = new FantasyStickLifecycleNativeTest.Fixture(directory); fixture.plugin.setEnabled(true);
|
||||
actor = new FantasyStickLifecycleNativeTest.Actor(directory); fixture.players.put(actor.core.id, actor.player);
|
||||
when(actor.nativePlayer.getInventory()).thenReturn(new net.minecraft.world.entity.player.Inventory(actor.nativePlayer,
|
||||
new net.minecraft.world.entity.EntityEquipment()));
|
||||
doReturn(0).when(actor.player).getLevel(); doReturn(true).when(actor.player).isOnline();
|
||||
var repository = new YamlCharacterRepository(directory); var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
repository.replace(withCoins(old, coins), old.revision()); fixture.activate(actor);
|
||||
var management = (PartyManagement) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "partyManagement");
|
||||
management.ready().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var registrations = (PartyRegistrarDirectory) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "partyRegistrars");
|
||||
var location = actor.core.location; registrar = registrations.place("Central", actor.world.getUID(), location.getX(),
|
||||
location.getY(), location.getZ(), location.getYaw(), location.getPitch()).get(5, TimeUnit.SECONDS);
|
||||
parties = (CharacterPartyDirectory) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characterParties");
|
||||
characters = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
villager = mock(Villager.class); when(villager.getLocation()).thenReturn(location);
|
||||
when(villager.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
var values = new HashMap<String, String>(); values.put("managed_npc_type", "party_registrar");
|
||||
values.put("party_registrar_id", registrar.id().toString());
|
||||
var pdc = mock(PersistentDataContainer.class); when(villager.getPersistentDataContainer()).thenReturn(pdc);
|
||||
when(pdc.get(any(NamespacedKey.class), eq(PersistentDataType.STRING)))
|
||||
.thenAnswer(call -> values.get(((NamespacedKey) call.getArgument(0)).getKey()));
|
||||
interact();
|
||||
}
|
||||
|
||||
void interact() {
|
||||
var interaction = new PlayerInteractEntityEvent(actor.player, villager, EquipmentSlot.HAND);
|
||||
fixture.dispatcher.accept(interaction); assertTrue(interaction.isCancelled());
|
||||
}
|
||||
|
||||
void reenter() throws Exception {
|
||||
actor.core.inventory.setItemInOffHand(null); fixture.changed(actor);
|
||||
PartyBookNativeTest.drain(fixture); fixture.awaitCharacterAndReceipt(actor);
|
||||
var current = new YamlCharacterRepository(directory).load(actor.core.record.id()).orElseThrow();
|
||||
actor.core.inventory.setItemInOffHand(new CharacterSheets().create(current)); fixture.activate(actor); interact();
|
||||
}
|
||||
|
||||
AnvilView openName() {
|
||||
var root = actor.inventories.getLast();
|
||||
var rootClick = new InventoryClickEvent(view(actor, root), InventoryType.SlotType.CONTAINER, 11,
|
||||
ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
fixture.dispatcher.accept(rootClick); assertTrue(rootClick.isCancelled());
|
||||
var opened = actor.inventoryViews.getLast();
|
||||
assertInstanceOf(org.bukkit.craftbukkit.inventory.view.CraftAnvilView.class, opened);
|
||||
assertEquals(org.bukkit.inventory.MenuType.ANVIL, opened.getMenuType());
|
||||
return (AnvilView) opened;
|
||||
}
|
||||
|
||||
ItemStack prepare(AnvilView view, String name) {
|
||||
var nativeMenu = assertInstanceOf(AnvilMenu.class,
|
||||
((org.bukkit.craftbukkit.inventory.CraftInventoryView<?, ?>) view).getHandle());
|
||||
assertTrue(nativeMenu.setItemName(name) || name.equals(view.getRenameText()), "Native rename input was rejected");
|
||||
return view.getTopInventory().getItem(2);
|
||||
}
|
||||
|
||||
InventoryClickEvent submit(AnvilView view) { return submit(view, false); }
|
||||
|
||||
InventoryClickEvent submit(AnvilView view, boolean cancelled) {
|
||||
var event = new InventoryClickEvent(view, InventoryType.SlotType.RESULT, 2,
|
||||
ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
event.setCancelled(cancelled); fixture.dispatcher.accept(event); return event;
|
||||
}
|
||||
|
||||
void assertUnchanged(BigInteger coins) throws Exception {
|
||||
characters.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS); parties.defeat(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
assertTrue(parties.partyOf(actor.core.record.id()).isEmpty());
|
||||
assertEquals(coins, characters.find(actor.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow().coins());
|
||||
}
|
||||
|
||||
void await(BooleanSupplier condition) throws Exception {
|
||||
for (int attempt = 0; attempt < 160 && !condition.getAsBoolean(); attempt++) {
|
||||
var task = fixture.tasks.poll(25, TimeUnit.MILLISECONDS); if (task != null) task.run();
|
||||
if (!condition.getAsBoolean()) Thread.sleep(5);
|
||||
}
|
||||
assertTrue(condition.getAsBoolean(), () -> "condition not reached; messages=" + actor.messages + "; parties=" + parties.all());
|
||||
}
|
||||
|
||||
@Override public void close() throws java.io.IOException { fixture.close(); }
|
||||
}
|
||||
|
||||
private static CharacterRecord withCoins(CharacterRecord old, BigInteger coins) {
|
||||
return new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), old.dead(), old.deathId(), old.diedAt(), coins, true, old.rewardReceipts(), old.pvpCoinEscrow(),
|
||||
old.gear(), old.sheetGeneration());
|
||||
}
|
||||
|
||||
private static org.bukkit.inventory.InventoryView view(FantasyStickLifecycleNativeTest.Actor actor, Inventory top) {
|
||||
return new org.bukkit.craftbukkit.inventory.CraftAbstractInventoryView() {
|
||||
@Override public Inventory getTopInventory() { return top; }
|
||||
@Override public PlayerInventory getBottomInventory() { return actor.core.inventory; }
|
||||
@Override public org.bukkit.entity.HumanEntity getPlayer() { return actor.player; }
|
||||
@Override public InventoryType getType() { return top.getType(); }
|
||||
@Override public ItemStack getCursor() { return actor.core.cursor; }
|
||||
@Override public void setCursor(ItemStack item) { actor.core.cursor = item; }
|
||||
@Override public String getTitle() { return "Party Registrar"; }
|
||||
@Override public String getOriginalTitle() { return getTitle(); }
|
||||
@Override public void setTitle(String title) { throw new AssertionError("Unexpected title mutation"); }
|
||||
@Override public MenuType getMenuType() { return null; }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ class PartyRegistrarNativeTest {
|
||||
@Test void adminPlacementReconcilesProtectedVillagerAndActiveCharacterCanPayToRegister()throws Exception{
|
||||
UUID invitedPlayer,invitedCharacter,invitedSession;
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);
|
||||
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);when(actor.nativePlayer.getInventory()).thenReturn(new net.minecraft.world.entity.player.Inventory(actor.nativePlayer,new net.minecraft.world.entity.EntityEquipment()));
|
||||
doReturn(true).when(actor.player).hasPermission("fantasystick.admin");UUID worldId=UUID.randomUUID();when(actor.world.getUID()).thenReturn(worldId);
|
||||
int chunkX=actor.core.location.getBlockX()>>4,chunkZ=actor.core.location.getBlockZ()>>4;var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);when(chunk.getX()).thenReturn(chunkX);when(chunk.getZ()).thenReturn(chunkZ);when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[0]);when(actor.world.getChunkAt(chunkX,chunkZ)).thenReturn(chunk);when(actor.world.getChunkAt(any(Location.class))).thenReturn(chunk);when(actor.world.isChunkLoaded(chunkX,chunkZ)).thenReturn(true);var worldEntities=new ArrayList<org.bukkit.entity.Entity>();when(actor.world.getEntities()).thenAnswer(call->List.copyOf(worldEntities));
|
||||
var villager=mock(Villager.class);when(villager.getUniqueId()).thenReturn(UUID.randomUUID());when(villager.getLocation()).thenReturn(actor.core.location);var values=new HashMap<String,String>();var pdc=mock(PersistentDataContainer.class);when(villager.getPersistentDataContainer()).thenReturn(pdc);doAnswer(call->{values.put(((NamespacedKey)call.getArgument(0)).getKey(),call.getArgument(2));return null;}).when(pdc).set(any(NamespacedKey.class),eq(PersistentDataType.STRING),anyString());when(pdc.get(any(NamespacedKey.class),eq(PersistentDataType.STRING))).thenAnswer(call->values.get(((NamespacedKey)call.getArgument(0)).getKey()));
|
||||
@@ -54,7 +54,7 @@ class PartyRegistrarNativeTest {
|
||||
var directory=(games.dmg.fantasystick.party.PartyRegistrarDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrars");var registrar=directory.all().getFirst();assertEquals("Central",registrar.name());
|
||||
var old=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,null,null,BigInteger.valueOf(20),true,old.rewardReceipts()),old.revision());f.activate(actor);var management=(games.dmg.fantasystick.party.PartyManagement)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyManagement");management.ready().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);assertTrue(management.active(actor.player,registrar.id()).isPresent());f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"config","party-registration-price","7"});for(int attempt=0;attempt<10&&actor.messages.stream().noneMatch(message->message.contains("Saved party-registration-price=7"));attempt++){response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();}f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"config","party-leader-change-price","6"});for(int attempt=0;attempt<10&&actor.messages.stream().noneMatch(message->message.contains("Saved party-leader-change-price=6"));attempt++){response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();}f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"config","party-registration-price"});f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"config","party-leader-change-price"});var controller=(games.dmg.fantasystick.party.PartyRegistrarController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarController");assertTrue(controller.tagged(villager));
|
||||
int openMenus=actor.inventories.size(),openBooks=actor.books.size();var mainInteraction=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);f.dispatcher.accept(mainInteraction);var offhandDuplicate=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.OFF_HAND);f.dispatcher.accept(offhandDuplicate);assertTrue(mainInteraction.isCancelled());assertTrue(offhandDuplicate.isCancelled());assertEquals(openMenus+1,actor.inventories.size());assertEquals(openBooks,actor.books.size());assertEquals(Material.WRITABLE_BOOK,actor.inventories.getLast().getItem(11).getType());var vetoedInteraction=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);vetoedInteraction.setCancelled(true);f.dispatcher.accept(vetoedInteraction);assertEquals(openMenus+1,actor.inventories.size());assertEquals(openBooks,actor.books.size());
|
||||
var menus=(games.dmg.fantasystick.party.PartyRegistrarMenus)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarMenus");var root=actor.inventories.getLast();assertEquals(net.kyori.adventure.text.Component.text("Register a party — 7 coins"),root.getItem(11).getItemMeta().displayName());var openName=mock(org.bukkit.event.inventory.InventoryClickEvent.class);when(openName.getInventory()).thenReturn(root);when(openName.getWhoClicked()).thenReturn(actor.player);when(openName.getCurrentItem()).thenReturn(root.getItem(11));menus.click(openName);var anvil=actor.inventories.getLast();var submit=mock(org.bukkit.event.inventory.InventoryClickEvent.class);when(submit.getInventory()).thenReturn(anvil);when(submit.getWhoClicked()).thenReturn(actor.player);when(submit.getRawSlot()).thenReturn(2);var anvilView=mock(org.bukkit.inventory.view.AnvilView.class);when(anvilView.getRenameText()).thenReturn(" Cafe\u0301 Heroes ");when(submit.getView()).thenReturn(anvilView);menus.click(submit);var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");characters.find(UUID.randomUUID()).join();var parties=(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals("Café Heroes",parties.partyOf(old.id()).orElseThrow().displayName());assertEquals(BigInteger.valueOf(13),characters.find(old.id()).join().orElseThrow().coins());
|
||||
var menus=(games.dmg.fantasystick.party.PartyRegistrarMenus)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarMenus");var root=actor.inventories.getLast();assertEquals(net.kyori.adventure.text.Component.text("Register a party — 7 coins"),root.getItem(11).getItemMeta().displayName());var openName=mock(org.bukkit.event.inventory.InventoryClickEvent.class);when(openName.getInventory()).thenReturn(root);when(openName.getWhoClicked()).thenReturn(actor.player);when(openName.getCurrentItem()).thenReturn(root.getItem(11));menus.click(openName);var anvilView=(org.bukkit.inventory.view.AnvilView)actor.inventoryViews.getLast();var nativeMenu=(net.minecraft.world.inventory.AnvilMenu)((org.bukkit.craftbukkit.inventory.CraftInventoryView<?,?>)anvilView).getHandle();assertTrue(nativeMenu.setItemName(" Cafe\u0301 Heroes "));var submit=new org.bukkit.event.inventory.InventoryClickEvent(anvilView,org.bukkit.event.inventory.InventoryType.SlotType.RESULT,2,org.bukkit.event.inventory.ClickType.LEFT,org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);f.dispatcher.accept(submit);assertTrue(submit.isCancelled());var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");characters.find(UUID.randomUUID()).join();var parties=(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals("Café Heroes",parties.partyOf(old.id()).orElseThrow().displayName());assertEquals(BigInteger.valueOf(13),characters.find(old.id()).join().orElseThrow().coins());
|
||||
var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(actor.world);f.players.put(member.core.id,member.player);f.activate(member);invitedPlayer=member.core.id;invitedCharacter=member.core.record.id();invitedSession=((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(member.core.id).orElseThrow().session();var leaderRoot=actor.inventories.getLast();click(menus,actor.player,leaderRoot,10,false);var eligible=actor.inventories.getLast();click(menus,actor.player,eligible,0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();click(menus,actor.player,actor.inventories.getLast(),22,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.invitationsFor(member.core.record.id()).isEmpty());click(menus,actor.player,actor.inventories.getLast(),10,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));var memberRoot=member.inventories.getLast();click(menus,member.player,memberRoot,15,false);var invitationMenu=member.inventories.getLast();click(menus,member.player,invitationMenu,0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.partyOf(member.core.record.id()).isPresent());controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));click(menus,actor.player,actor.inventories.getLast(),13,false);assertEquals(List.of(net.kyori.adventure.text.Component.text("Left click: Kick (free)"),net.kyori.adventure.text.Component.text("Right click: Transfer leadership — 6 coins")),actor.inventories.getLast().getItem(0).getItemMeta().lore());click(menus,actor.player,actor.inventories.getLast(),0,true);characters.find(UUID.randomUUID()).join();parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals(member.core.record.id(),parties.partyOf(old.id()).orElseThrow().leader());assertEquals(BigInteger.valueOf(7),characters.find(old.id()).join().orElseThrow().coins());
|
||||
controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));click(menus,member.player,member.inventories.getLast(),13,false);click(menus,member.player,member.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.partyOf(old.id()).isEmpty());controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));click(menus,member.player,member.inventories.getLast(),10,false);click(menus,member.player,member.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));click(menus,actor.player,actor.inventories.getLast(),15,false);click(menus,actor.player,actor.inventories.getLast(),0,true);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));click(menus,member.player,member.inventories.getLast(),10,false);click(menus,member.player,member.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));click(menus,actor.player,actor.inventories.getLast(),15,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));click(menus,actor.player,actor.inventories.getLast(),13,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));click(menus,member.player,member.inventories.getLast(),16,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.partyOf(member.core.record.id()).isEmpty());
|
||||
f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"party-registrar","list"});assertTrue(actor.messages.stream().anyMatch(message->message.contains(registrar.id().toString())&&message.contains("Central")));when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[]{villager});controller.reconcile(chunk);verify(actor.world,times(1)).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));var damage=new org.bukkit.event.entity.EntityDamageEvent(villager,org.bukkit.event.entity.EntityDamageEvent.DamageCause.FIRE,1);controller.damage(damage);assertTrue(damage.isCancelled());var combust=new org.bukkit.event.entity.EntityCombustEvent(villager,5);controller.combust(combust);assertTrue(combust.isCancelled());var interaction=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);controller.interact(interaction);assertTrue(interaction.isCancelled());var leash=new org.bukkit.event.entity.PlayerLeashEntityEvent(villager,actor.player,actor.player,EquipmentSlot.HAND);controller.leash(leash);assertTrue(leash.isCancelled());
|
||||
|
||||
@@ -319,7 +319,7 @@ class ProtocolPresentationNativeTest {
|
||||
assertInstanceOf(ClientboundBundlePacket.class, event.getPacket().getHandle()).subPackets().forEach(packets::add);
|
||||
assertEquals(2, packets.size());
|
||||
assertSame(original, packets.getFirst()); assertSame(unrelated, packets.getLast());
|
||||
assertArrayEquals(new int[] {label}, f.packets(f.viewer, ClientboundRemoveEntitiesPacket.class).getLast().getEntityIds().toIntArray());
|
||||
assertArrayEquals(new int[] {label}, f.packets(f.viewer, ClientboundRemoveEntitiesPacket.class).getLast().entityIds().toIntArray());
|
||||
assertEquals(1, f.packets(f.viewer, ClientboundRemoveEntitiesPacket.class).size());
|
||||
assertTrue(packets.stream().noneMatch(ClientboundBundlePacket.class::isInstance));
|
||||
verifyNoInteractions(errors);
|
||||
@@ -484,7 +484,7 @@ class ProtocolPresentationNativeTest {
|
||||
var sent = f.sent.get(i);
|
||||
if (sent.viewer() != f.viewer) { continue; }
|
||||
if (sent.packet() instanceof ClientboundAddEntityPacket spawn && spawn.getId() == oldId) { oldSpawn = i; }
|
||||
if (sent.packet() instanceof ClientboundRemoveEntitiesPacket remove && remove.getEntityIds().contains(oldId)) { oldRemove = i; }
|
||||
if (sent.packet() instanceof ClientboundRemoveEntitiesPacket remove && remove.entityIds().contains(oldId)) { oldRemove = i; }
|
||||
}
|
||||
assertTrue(oldSpawn >= 0 && oldRemove > oldSpawn, "No old label may escape after its cleanup");
|
||||
assertEquals(2, f.packets(f.viewer, ClientboundAddEntityPacket.class).size());
|
||||
@@ -528,7 +528,7 @@ class ProtocolPresentationNativeTest {
|
||||
f.tick.run(); f.snapshot.set(Map.of()); f.output.clear(newer);
|
||||
var removed = new java.util.HashSet<Integer>();
|
||||
f.packets(f.viewer, ClientboundRemoveEntitiesPacket.class).forEach(packet -> {
|
||||
for (int id : packet.getEntityIds().toIntArray()) { removed.add(id); }
|
||||
for (int id : packet.entityIds().toIntArray()) { removed.add(id); }
|
||||
});
|
||||
for (var spawn : f.packets(f.viewer, ClientboundAddEntityPacket.class)) {
|
||||
assertTrue(removed.contains(spawn.getId()), "Every escaped label remains owned until cleanup: " + spawn.getId());
|
||||
@@ -866,7 +866,7 @@ class ProtocolPresentationNativeTest {
|
||||
assertSame(original, event.getPacket().getHandle(), "Retain native removal/concealment packet and every field");
|
||||
var removed = wire(ClientboundRemoveEntitiesPacket.STREAM_CODEC,
|
||||
fixture.packets(fixture.viewer, ClientboundRemoveEntitiesPacket.class).getLast());
|
||||
assertArrayEquals(new int[] {label}, removed.getEntityIds().toIntArray());
|
||||
assertArrayEquals(new int[] {label}, removed.entityIds().toIntArray());
|
||||
assertFalse(event.isCancelled());
|
||||
}
|
||||
|
||||
@@ -900,9 +900,9 @@ class ProtocolPresentationNativeTest {
|
||||
platform = mockStatic(org.bukkit.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";
|
||||
case "getVersion" -> "Purpur 2651 (MC: 26.3)";
|
||||
case "getMinecraftVersion" -> "26.3";
|
||||
case "getBukkitVersion" -> "26.3-R0.1-SNAPSHOT";
|
||||
default -> call.callRealMethod();
|
||||
});
|
||||
var config = new io.papermc.paper.configuration.GlobalConfiguration();
|
||||
|
||||
@@ -227,6 +227,8 @@ class TrainingDummyEventsNativeTest {
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(arrow, ports.entity(),
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, (org.bukkit.damage.DamageSource) call.getArgument(1), (Double) call.getArgument(0));
|
||||
assertSame(arrow, event.getDamageSource().getDirectEntity(), "The typed native source must retain the actual owned projectile");
|
||||
assertEquals(abilityId.equals("frost_shard") ? 20.0 : 7.0, event.getDamage(),
|
||||
"Frost Shard multiplies once before native modification; Fireball and Longbow remain unchanged");
|
||||
event.setDamage(4.875); hits.add(event); f.dispatcher.accept(event); return null;
|
||||
}).when(ports.entity()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
clearInvocations(ports.entity()); player.core.inventory.getInventory().setSelectedSlot(2);
|
||||
|
||||
@@ -27,7 +27,7 @@ class DistributionTest {
|
||||
assertEquals(version, metadata.getVersion());
|
||||
assertFalse(metadata.getVersion().contains("${"));
|
||||
assertEquals("games.dmg.fantasystick.FantasyStickPlugin", metadata.getMain());
|
||||
assertNotNull(metadata.getAPIVersion(), "Declare the native API compatibility contract");
|
||||
assertEquals("26.3", metadata.getAPIVersion(), "Declare the exact native API compatibility contract");
|
||||
assertEquals(java.util.List.of("ProtocolLib"), metadata.getDepend());
|
||||
assertEquals(java.util.Set.of("fstick", "fstickadmin"), metadata.getCommands().keySet());
|
||||
assertNull(metadata.getCommands().get("fstick").get("permission"),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class PurpurDistributionTest {
|
||||
private static final String API = "org.purpurmc.purpur:purpur-api:26.3.build.2651-stable";
|
||||
private static final String RUNTIME = "purpur-26.3-2651.jar";
|
||||
private static final String DOWNLOAD = "https://api.purpurmc.org/v2/purpur/26.3/2651/download";
|
||||
private static final String SHA256 = "0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598";
|
||||
|
||||
@Test
|
||||
void pinsTheExactApprovedApiAndRuntime() throws Exception {
|
||||
String build = Files.readString(Path.of("build.gradle.kts"));
|
||||
assertTrue(build.contains("compileOnly(\"" + API + "\")"));
|
||||
assertTrue(build.contains("testImplementation(\"" + API + "\")"));
|
||||
assertTrue(build.contains(RUNTIME));
|
||||
assertTrue(build.contains(DOWNLOAD));
|
||||
assertTrue(build.contains(SHA256));
|
||||
assertFalse(build.contains("26.2.build.2618-stable"));
|
||||
assertFalse(build.contains("purpur-26.2-2618.jar"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void retainsTheApprovedProtocolLibDependency() throws Exception {
|
||||
String build = Files.readString(Path.of("build.gradle.kts"));
|
||||
String descriptor = Files.readString(Path.of("src/main/resources/plugin.yml"));
|
||||
assertTrue(build.contains("compileOnly(\"net.dmulloy2:ProtocolLib:5.4.0\")"));
|
||||
assertTrue(build.contains("testImplementation(\"net.dmulloy2:ProtocolLib:5.4.0\")"));
|
||||
assertTrue(descriptor.contains("depend: [ProtocolLib]"));
|
||||
}
|
||||
}
|
||||
@@ -14,28 +14,30 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
class ChainLightningSettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void defaultsAndParsingRetainExactDecay() {
|
||||
var settings = ChainLightningSettings.parse(defaults());
|
||||
assertEquals(java.math.BigInteger.valueOf(8), settings.baseDamage());
|
||||
@Test void defaultsAndParsingExposeOnlyEffectivePolicyAndIgnoreObsoleteDamageKeys() {
|
||||
var expected = java.util.List.of(ChainLightningSettings.COOLDOWN, ChainLightningSettings.INITIAL_RANGE,
|
||||
ChainLightningSettings.JUMP_RANGE, ChainLightningSettings.MAX_TARGETS);
|
||||
assertEquals(expected, ChainLightningSettings.keys());
|
||||
assertEquals("20", ChainLightningSettings.defaultValue(ChainLightningSettings.MAX_TARGETS));
|
||||
var values = defaults();
|
||||
values.put("chain-lightning-base-damage", java.math.BigInteger.TEN.pow(500).toString());
|
||||
values.put("chain-lightning-additional-targets", "0");
|
||||
values.put("chain-lightning-jump-multiplier", "0.01");
|
||||
values.put("chain-lightning-target-int-step", "1");
|
||||
var settings = ChainLightningSettings.parse(values);
|
||||
assertEquals(java.time.Duration.ofSeconds(18), settings.cooldown());
|
||||
assertEquals(16, settings.initialRange(), 0); assertEquals(6, settings.jumpRange(), 0);
|
||||
assertEquals(4, settings.additionalTargets());
|
||||
assertEquals(new java.math.BigDecimal("0.75"), settings.jumpMultiplier());
|
||||
assertEquals(20, settings.maxTargets());
|
||||
}
|
||||
|
||||
@Test void targetCoverageKeysAndPackagedDefaultsAreAvailable() {
|
||||
assertTrue(ChainLightningSettings.keys().containsAll(java.util.List.of("chain-lightning-target-int-step", "chain-lightning-max-targets")));
|
||||
assertEquals("5", ChainLightningSettings.defaultValue("chain-lightning-target-int-step"));
|
||||
assertEquals("12", ChainLightningSettings.defaultValue("chain-lightning-max-targets"));
|
||||
@Test void configuredChainCeilingSupportsOnlyOneThroughTwentyTargets() {
|
||||
var values = defaults();
|
||||
for (String invalid : java.util.List.of("0", "-1", "1.5")) {
|
||||
values.put("chain-lightning-target-int-step", invalid);
|
||||
assertThrows(IllegalArgumentException.class, () -> ChainLightningSettings.parse(values));
|
||||
for (String valid : java.util.List.of("1", "19", "20")) {
|
||||
values.put(ChainLightningSettings.MAX_TARGETS, valid);
|
||||
assertEquals(Integer.parseInt(valid), ChainLightningSettings.parse(values).maxTargets());
|
||||
}
|
||||
values.put("chain-lightning-target-int-step", java.math.BigInteger.TEN.pow(500).toString());
|
||||
assertDoesNotThrow(() -> ChainLightningSettings.parse(values));
|
||||
for (String invalid : java.util.List.of("0", "65", "1.5")) {
|
||||
values.put("chain-lightning-max-targets", invalid);
|
||||
for (String invalid : java.util.List.of("0", "21", "65", "1.5")) {
|
||||
values.put(ChainLightningSettings.MAX_TARGETS, invalid);
|
||||
assertThrows(IllegalArgumentException.class, () -> ChainLightningSettings.parse(values));
|
||||
}
|
||||
}
|
||||
@@ -61,8 +63,7 @@ class ChainLightningSettingsTest {
|
||||
try (var settings = new ChainLightningSettings(data, defaults())) {
|
||||
for (var entry : Map.of(
|
||||
ChainLightningSettings.COOLDOWN, "-1", ChainLightningSettings.INITIAL_RANGE, "0",
|
||||
ChainLightningSettings.JUMP_RANGE, "0", ChainLightningSettings.ADDITIONAL, "-1",
|
||||
ChainLightningSettings.BASE, "-1", ChainLightningSettings.MULTIPLIER, "0").entrySet()) {
|
||||
ChainLightningSettings.JUMP_RANGE, "0", ChainLightningSettings.MAX_TARGETS, "21").entrySet()) {
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(ChainLightningSettings.COOLDOWN, "0.0000000001"));
|
||||
@@ -82,13 +83,13 @@ class ChainLightningSettingsTest {
|
||||
Files.createSymbolicLink(unsafe.resolve("config.yml"), outside);
|
||||
try (var settings = new ChainLightningSettings(unsafe, defaults())) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set(ChainLightningSettings.MULTIPLIER, "0.5").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
() -> settings.set(ChainLightningSettings.MAX_TARGETS, "10").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("safe\n", Files.readString(outside));
|
||||
}
|
||||
}
|
||||
var closed = new ChainLightningSettings(data.resolve("closed"), defaults()); closed.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> closed.set(ChainLightningSettings.BASE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
() -> closed.set(ChainLightningSettings.MAX_TARGETS, "19").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user