Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b12d4cb5ca | ||
|
|
df6474fd55 | ||
|
|
edada37669 | ||
|
|
608a5fd2aa | ||
|
|
04e224ad95 | ||
|
|
b3473aff18 | ||
|
|
a2140bd8d7 |
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.
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
-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()) {
|
||||
|
||||
@@ -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");
|
||||
@@ -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; }
|
||||
|
||||
+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());
|
||||
|
||||
@@ -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