Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8fed8998b | ||
|
|
9837afc549 |
No files matched your search
@@ -335,7 +335,8 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
} else if (award.status() == CharacterStorage.CombatAwardStatus.UNCHANGED) {
|
||||
player.sendMessage("No fantasy XP: this target's configured reward is zero.");
|
||||
}
|
||||
if (operation.coins.signum() > 0 && award.status() != CharacterStorage.CombatAwardStatus.DUPLICATE) {
|
||||
if (operation.coins.signum() > 0 && award.status() != CharacterStorage.CombatAwardStatus.DUPLICATE
|
||||
&& award.status() != CharacterStorage.CombatAwardStatus.SPACING_BLOCKED) {
|
||||
player.sendMessage("Earned " + operation.coins + " character coins.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,7 +378,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
|
||||
old.loadout(), old.passives(), old.cooldowns(), history, false, old.deathId(), old.diedAt(),
|
||||
old.coins().add(coinReward), true, receipts,old.pvpCoinEscrow(),old.gear());
|
||||
old.coins().add(eligible ? coinReward : BigInteger.ZERO), true, receipts,old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new CombatAward(status, Optional.of(saved), gained);
|
||||
});
|
||||
|
||||
@@ -752,7 +752,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
UUID owner = event.getPlayer().getUniqueId();
|
||||
cancelCustody(owner);
|
||||
swaps.remove(owner);
|
||||
sessions.invalidateTail(owner);
|
||||
sessions.invalidateTail(owner, event.getView());
|
||||
}
|
||||
|
||||
private boolean reservedExchange(Player player, int slot) {
|
||||
|
||||
@@ -114,7 +114,12 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
boolean bookDelivered;
|
||||
boolean deliveryInvalidated;
|
||||
boolean conversionReported;
|
||||
boolean partyDefeatAcknowledged;
|
||||
boolean tailCallbackQueued;
|
||||
boolean defeatReturnTeleport;
|
||||
CharacterRecord deathRecord;
|
||||
ItemStack deathReference;
|
||||
Object deathNativeReference;
|
||||
org.bukkit.inventory.InventoryView deathView;
|
||||
Attempt(Player player, UUID owner, UUID character) {
|
||||
this.player = player; this.owner = owner; this.character = character;
|
||||
@@ -426,7 +431,12 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
if (active.containsKey(owner)) return;
|
||||
cancelEntry(owner);
|
||||
if (owned.containsKey(owner)) {
|
||||
var previous = owned.get(owner);
|
||||
if (previous != null && previous.defeated) {
|
||||
retryTail(player, previous);
|
||||
return;
|
||||
}
|
||||
if (previous != null) {
|
||||
notice(owner, "BUSY", "Equipment persistence or recovery is pending; retry after it completes.");
|
||||
return;
|
||||
}
|
||||
@@ -673,30 +683,35 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
if (failure != null) { diagnostic(attempt, failure); conversionUncertain(attempt, "Death save failed or is uncertain"); return; }
|
||||
if (dead.isEmpty()) { conversionUncertain(attempt, "Character was missing or already dead; no new Tail authorized"); return; }
|
||||
attempt.deathResolved = true;
|
||||
attempt.deathRecord = dead.orElseThrow();
|
||||
if (closed) { conversionUncertain(attempt, "Shutdown before party defeat and Tail conversion"); return; }
|
||||
java.util.concurrent.CompletionStage<?> partyMutation;
|
||||
try { partyMutation = partyDefeat.apply(attempt.character); }
|
||||
catch (RuntimeException error) { diagnostic(attempt,error); conversionUncertain(attempt,"Party defeat mutation rejected"); return; }
|
||||
partyMutation.whenComplete((ignored,partyFailure) -> {
|
||||
if (partyFailure != null) { diagnostic(attempt,partyFailure); conversionUncertain(attempt,"Party defeat mutation failed"); return; }
|
||||
if (closed) { conversionUncertain(attempt,"Shutdown before Tail conversion"); return; }
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
synchronized (EquipmentSessions.this) { deliverTail(attempt, dead.orElseThrow()); }
|
||||
});
|
||||
} catch (RuntimeException error) { diagnostic(attempt, error); conversionUncertain(attempt, "Tail callback rejected"); }
|
||||
synchronized (EquipmentSessions.this) {
|
||||
if (partyFailure != null) { diagnostic(attempt,partyFailure); conversionUncertain(attempt,"Party defeat mutation failed"); return; }
|
||||
if (closed) { conversionUncertain(attempt,"Shutdown before Tail conversion"); return; }
|
||||
attempt.partyDefeatAcknowledged = true;
|
||||
queueTail(attempt);
|
||||
}
|
||||
});
|
||||
});
|
||||
try {
|
||||
attempt.deathReference = copy(attempt.player.getInventory().getItemInOffHand());
|
||||
attempt.deathView = attempt.player.getOpenInventory();
|
||||
restoreExit(attempt);
|
||||
var restored = restoreExit(attempt);
|
||||
if (restored == EquipmentStorage.RestoreResult.RESTORED) {
|
||||
var retained = attempt.player.getInventory().getItemInOffHand();
|
||||
attempt.deathReference = copy(retained);
|
||||
attempt.deathNativeReference = nativeIdentity(retained);
|
||||
attempt.deathView = attempt.player.getOpenInventory();
|
||||
}
|
||||
// Read the current maximum after owned overlay cleanup, including legitimate
|
||||
// ordinary modifiers. Never reconstruct attributes from an entry snapshot.
|
||||
double maximum = Objects.requireNonNull(attempt.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue();
|
||||
event.setReviveHealth(maximum);
|
||||
attempt.player.setHealth(maximum); // Native cancellation only fills health when <= 0.
|
||||
notice(attempt.owner, "DEFEAT", "§cYour character was defeated.");
|
||||
attempt.defeatReturnTeleport = true;
|
||||
try {
|
||||
if (!attempt.player.teleport(attempt.entryLocation.clone())) {
|
||||
notice(attempt.owner, "DEFEAT_RETURN_FAILED", "Return to the session entry location was refused; you remain here.");
|
||||
@@ -704,6 +719,10 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
} catch (RuntimeException failure) {
|
||||
diagnostic(attempt, failure);
|
||||
notice(attempt.owner, "DEFEAT_RETURN_FAILED", "Return to the session entry location failed; you remain here.");
|
||||
} finally {
|
||||
attempt.defeatReturnTeleport = false;
|
||||
if (!attempt.deliveryInvalidated && owned.get(attempt.owner) == attempt && attempt.player.isOnline())
|
||||
attempt.deathView = attempt.player.getOpenInventory();
|
||||
}
|
||||
} catch (RuntimeException failure) {
|
||||
diagnostic(attempt, failure);
|
||||
@@ -727,21 +746,66 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
if (attempt != null && attempt.defeated) attempt.deliveryInvalidated = true;
|
||||
}
|
||||
|
||||
private void deliverTail(Attempt attempt, CharacterRecord dead) {
|
||||
if (attempt.bookAttempted) return;
|
||||
attempt.bookAttempted = true; // Even an exception after a native write never permits replay.
|
||||
/** The exact view closed by this plugin's defeat-return teleport may be rebound after teleport. */
|
||||
public synchronized void invalidateTail(UUID owner, org.bukkit.inventory.InventoryView closedView) {
|
||||
var attempt = owned.get(owner);
|
||||
if (attempt == null || !attempt.defeated) return;
|
||||
if (attempt.defeatReturnTeleport && attempt.deathView == closedView) {
|
||||
attempt.deathView = null;
|
||||
return;
|
||||
}
|
||||
attempt.deliveryInvalidated = true;
|
||||
}
|
||||
|
||||
private void queueTail(Attempt attempt) {
|
||||
if (closed || attempt.tailCallbackQueued || attempt.bookAttempted || attempt.bookDelivered
|
||||
|| attempt.deliveryInvalidated || !attempt.deathResolved || !attempt.partyDefeatAcknowledged
|
||||
|| !attempt.gearAcknowledged || attempt.deathRecord == null) return;
|
||||
attempt.tailCallbackQueued = true;
|
||||
try {
|
||||
if (!tailContext(attempt)
|
||||
|| !Objects.equals(attempt.deathReference, copy(attempt.player.getInventory().getItemInOffHand()))
|
||||
|| !sheets.identify(attempt.deathReference).filter(attempt.character::equals).isPresent()
|
||||
|| attempt.deathReference.getAmount() < 1
|
||||
|| attempt.deathReference.getAmount() > attempt.deathReference.getMaxStackSize()) {
|
||||
nextTick.accept(() -> {
|
||||
synchronized (EquipmentSessions.this) {
|
||||
attempt.tailCallbackQueued = false;
|
||||
deliverTail(attempt, attempt.deathRecord);
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException error) {
|
||||
attempt.tailCallbackQueued = false;
|
||||
conversionNotStarted(attempt, "Tail callback was rejected before its native write ("
|
||||
+ error.getClass().getSimpleName() + ")");
|
||||
}
|
||||
}
|
||||
|
||||
private void retryTail(Player player, Attempt attempt) {
|
||||
if (attempt.player != player || !player.isOnline() || attempt.bookAttempted || attempt.bookDelivered
|
||||
|| attempt.deliveryInvalidated || !tailCustody(attempt)) {
|
||||
attempt.deliveryInvalidated = true;
|
||||
notice(attempt.owner, "DEFEAT_INSPECTION_REQUIRED",
|
||||
"Death or Tail custody is ambiguous; items were retained for administrator inspection.");
|
||||
return;
|
||||
}
|
||||
if (!attempt.deathResolved || !attempt.partyDefeatAcknowledged || !attempt.gearAcknowledged
|
||||
|| attempt.deathRecord == null) {
|
||||
notice(attempt.owner, "DEFEAT_PENDING",
|
||||
"Death persistence, restoration or Tail prerequisites are still pending; no item was changed.");
|
||||
return;
|
||||
}
|
||||
attempt.tailCallbackQueued = false;
|
||||
deliverTail(attempt, attempt.deathRecord);
|
||||
}
|
||||
|
||||
private void deliverTail(Attempt attempt, CharacterRecord dead) {
|
||||
if (attempt.bookAttempted || attempt.bookDelivered) return;
|
||||
try {
|
||||
if (!tailContext(attempt) || !tailCustody(attempt)) {
|
||||
conversionUncertain(attempt, "Tail destination/context is stale or equipment restoration is unresolved"); return;
|
||||
}
|
||||
// Constructing a book cannot mutate native inventory and remains retryable if it fails.
|
||||
var book = new games.dmg.fantasystick.items.TailBooks().create(dead);
|
||||
attempt.bookAttempted = true; // From this point an exception may follow a native write; never replay.
|
||||
// An exact captured stack of copied references represents ONE character, not N
|
||||
// deaths. Replace that whole stack with ONE book; never scan/delete other copies,
|
||||
// move items, overwrite a changed stack, or grant into an available slot.
|
||||
var book = new games.dmg.fantasystick.items.TailBooks().create(dead);
|
||||
attempt.player.getInventory().setItemInOffHand(book);
|
||||
if (!tailContext(attempt) || !book.equals(attempt.player.getInventory().getItemInOffHand())) {
|
||||
conversionUncertain(attempt, "Context changed during Tail write; delivery may already have happened"); return;
|
||||
@@ -750,11 +814,24 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
release(attempt);
|
||||
sync(attempt);
|
||||
} catch (RuntimeException failure) {
|
||||
diagnostic(attempt, failure);
|
||||
conversionUncertain(attempt, "Tail native write failed or is uncertain; never replay");
|
||||
if (attempt.bookAttempted) {
|
||||
diagnostic(attempt, failure);
|
||||
conversionUncertain(attempt, "Tail native write failed or is uncertain; never replay");
|
||||
} else conversionNotStarted(attempt, "Tail construction failed before its native write ("
|
||||
+ failure.getClass().getSimpleName() + ")");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean tailCustody(Attempt attempt) {
|
||||
var current = attempt.player.getInventory().getItemInOffHand();
|
||||
return attempt.deathReference != null && attempt.deathNativeReference != null
|
||||
&& Objects.equals(attempt.deathReference, copy(current))
|
||||
&& nativeIdentity(current) == attempt.deathNativeReference
|
||||
&& sheets.identify(attempt.deathReference).filter(attempt.character::equals).isPresent()
|
||||
&& attempt.deathReference.getAmount() >= 1
|
||||
&& attempt.deathReference.getAmount() <= attempt.deathReference.getMaxStackSize();
|
||||
}
|
||||
|
||||
private boolean tailContext(Attempt attempt) {
|
||||
if (closed || attempt.deliveryInvalidated || owned.get(attempt.owner) != attempt
|
||||
|| !attempt.player.isOnline() || attempt.player.getOpenInventory() != attempt.deathView) return false;
|
||||
@@ -763,6 +840,11 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
&& marker.phase() == EquipmentSessionMarker.Phase.RESTORED;
|
||||
}
|
||||
|
||||
private void conversionNotStarted(Attempt attempt, String reason) {
|
||||
diagnostic(attempt, new IllegalStateException(reason + "; character " + attempt.character
|
||||
+ ". The exact same-lifetime retained sheet may retry after acknowledged prerequisites."));
|
||||
}
|
||||
|
||||
private void conversionUncertain(Attempt attempt, String reason) {
|
||||
if (attempt.conversionReported) return;
|
||||
attempt.conversionReported = true;
|
||||
@@ -904,8 +986,18 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
? receipts.completeExit(attempt.owner, attempt.session, attempt.exitDelay)
|
||||
: receipts.recordExit(attempt.owner, attempt.session, attempt.exitDelay), (state, failure) -> {
|
||||
attempt.saving = false;
|
||||
if (failure != null) diagnostic(attempt, failure);
|
||||
else if (complete) { attempt.gearAcknowledged = true; release(attempt); }
|
||||
if (failure != null) {
|
||||
diagnostic(attempt, failure);
|
||||
if (attempt.defeated) conversionUncertain(attempt,
|
||||
"Equipment restoration persistence failed before Tail conversion");
|
||||
} else if (complete) {
|
||||
attempt.gearAcknowledged = true;
|
||||
if (attempt.defeated) queueTail(attempt);
|
||||
release(attempt);
|
||||
} else if (attempt.defeated) {
|
||||
conversionUncertain(attempt,
|
||||
"Equipment restoration was not acknowledged; Tail conversion remains unsafe");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -999,6 +1091,9 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
});
|
||||
} catch (RuntimeException failure) { fail(attempt, failure); }
|
||||
}
|
||||
private static Object nativeIdentity(ItemStack item) {
|
||||
return item == null || item.isEmpty() ? null : org.bukkit.craftbukkit.inventory.CraftItemStack.unwrap(item);
|
||||
}
|
||||
private static ItemStack copy(ItemStack item) { return item == null ? null : item.clone(); }
|
||||
private static ItemStack[] snapshot(Player player) {
|
||||
return Arrays.stream(player.getInventory().getContents()).map(EquipmentSessions::copy).toArray(ItemStack[]::new);
|
||||
|
||||
@@ -39,6 +39,108 @@ class CombatExperienceNativeTest {
|
||||
games.dmg.fantasystick.party.CombatExperience.coinTarget(witch));
|
||||
}
|
||||
|
||||
@Test void tooCloseHostileDeathDiscardsItsAllocatedCoinsAndPublishesNoCoinFeedback() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"hostile-coin-drop-min: 10\nhostile-coin-drop-max: 10\n");
|
||||
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.activate(actor);
|
||||
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
var first = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
|
||||
hit(f, actor.player, first, false); die(f, first); settle(f);
|
||||
var eligible = record(f, actor);
|
||||
int earnedCoinMessages = (int) actor.messages.stream().filter(message -> message.contains("character coins")).count();
|
||||
assertEquals(1, earnedCoinMessages);
|
||||
|
||||
var near = mob(EntityType.ZOMBIE, new Location(world, 1, 64, 0));
|
||||
hit(f, actor.player, near, false); die(f, near); settle(f);
|
||||
var blocked = record(f, actor);
|
||||
assertEquals(eligible.experience(), blocked.experience());
|
||||
assertEquals(eligible.coins(), blocked.coins(), "Spacing-blocked combat coins are discarded");
|
||||
assertEquals(earnedCoinMessages,
|
||||
actor.messages.stream().filter(message -> message.contains("character coins")).count());
|
||||
assertTrue(actor.messages.stream().anyMatch(message -> message.contains("too close")));
|
||||
assertEquals(new java.math.BigDecimal("1.0"), blocked.restrictedKills().values().iterator().next().x(),
|
||||
"Blocked attempts still advance the spacing reference");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void everyRestrictedMobCoinClassUsesTheExistingExactAndCrossWorldSpacingGeometry() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"hostile-coin-drop-min: 12\nhostile-coin-drop-max: 12\n"
|
||||
+ "boss-coin-drop-min: 12\nboss-coin-drop-max: 12\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var types = java.util.List.of(EntityType.ZOMBIE, EntityType.BEE, EntityType.WITCH,
|
||||
EntityType.WITHER, EntityType.WARDEN, EntityType.ENDER_DRAGON);
|
||||
for (var type : types) {
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(actor.core.id, actor.player); f.activate(actor);
|
||||
var firstWorld = mock(org.bukkit.World.class); when(firstWorld.getUID()).thenReturn(UUID.randomUUID());
|
||||
var otherWorld = mock(org.bukkit.World.class); when(otherWorld.getUID()).thenReturn(UUID.randomUUID());
|
||||
var initial = record(f, actor);
|
||||
var first = mob(type, new Location(firstWorld, 0, 64, 0));
|
||||
hit(f, actor.player, first, false); die(f, first); settle(f);
|
||||
var eligible = record(f, actor);
|
||||
var expectedCoins = type == EntityType.BEE ? java.math.BigInteger.ZERO : java.math.BigInteger.valueOf(12);
|
||||
assertEquals(initial.coins().add(expectedCoins), eligible.coins(), type + " first kill");
|
||||
int coinMessages = (int) actor.messages.stream().filter(message -> message.contains("character coins")).count();
|
||||
|
||||
var near = mob(type, new Location(firstWorld, 1, 64, 0));
|
||||
hit(f, actor.player, near, false); die(f, near); settle(f);
|
||||
var blocked = record(f, actor);
|
||||
assertEquals(eligible.experience(), blocked.experience(), type + " near XP");
|
||||
assertEquals(eligible.coins(), blocked.coins(), type + " near coins");
|
||||
assertEquals(coinMessages,
|
||||
actor.messages.stream().filter(message -> message.contains("character coins")).count(), type + " feedback");
|
||||
assertEquals(new java.math.BigDecimal("1.0"), blocked.restrictedKills().get(firstWorld.getUID()).x());
|
||||
|
||||
var exact = mob(type, new Location(firstWorld, 4, 64, 0));
|
||||
hit(f, actor.player, exact, false); die(f, exact); settle(f);
|
||||
var boundary = record(f, actor);
|
||||
assertTrue(boundary.experience().compareTo(blocked.experience()) > 0, type + " exact distance XP");
|
||||
assertEquals(blocked.coins().add(expectedCoins), boundary.coins(), type + " exact distance coins");
|
||||
|
||||
var acrossWorld = mob(type, new Location(otherWorld, 0, 64, 0));
|
||||
hit(f, actor.player, acrossWorld, false); die(f, acrossWorld); settle(f);
|
||||
var independent = record(f, actor);
|
||||
assertTrue(independent.experience().compareTo(boundary.experience()) > 0, type + " cross-world XP");
|
||||
assertEquals(boundary.coins().add(expectedCoins), independent.coins(), type + " cross-world coins");
|
||||
assertEquals(2, independent.restrictedKills().size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void partyRecipientsApplyIndependentSpacingWithoutRedistributingBlockedShares() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"hostile-coin-drop-min: 20\nhostile-coin-drop-max: 20\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a); f.activate(b);
|
||||
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
var history = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
|
||||
hit(f, a.player, history, false); die(f, history); settle(f);
|
||||
var beforeA = record(f, a); var beforeB = record(f, b);
|
||||
|
||||
var parties = PartyLifecycleNativeTest.parties(f); var party = parties.create(a.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(b.core.id, parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
|
||||
var shared = mob(EntityType.ZOMBIE, new Location(world, 1, 64, 0));
|
||||
hit(f, a.player, shared, false); die(f, shared); settle(f); settle(f);
|
||||
var afterA = record(f, a); var afterB = record(f, b);
|
||||
assertEquals(beforeA.experience(), afterA.experience());
|
||||
assertEquals(beforeA.coins(), afterA.coins(), "A's blocked half is discarded");
|
||||
assertEquals(beforeB.experience().add(java.math.BigInteger.valueOf(5)), afterB.experience());
|
||||
assertEquals(beforeB.coins().add(java.math.BigInteger.TEN), afterB.coins(), "B retains only B's half");
|
||||
assertEquals(new java.math.BigDecimal("1.0"), afterA.restrictedKills().get(world.getUID()).x());
|
||||
assertEquals(new java.math.BigDecimal("1.0"), afterB.restrictedKills().get(world.getUID()).x());
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("too close")));
|
||||
assertTrue(b.messages.stream().anyMatch(message -> message.contains("10 character coins")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void packagedWitchPoolFlowsThroughExactSoloAndPartyAllocationWithOrdinaryCoins() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
@@ -229,10 +331,11 @@ class CombatExperienceNativeTest {
|
||||
var original = new games.dmg.fantasystick.crafting.CharacterFactory().create(progression);
|
||||
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(UUID.randomUUID(), UUID.randomUUID(), original.id());
|
||||
var attacker = mock(org.bukkit.entity.Player.class);
|
||||
when(attacker.getUniqueId()).thenReturn(active.player());
|
||||
when(attacker.getUniqueId()).thenReturn(active.player()); when(attacker.isOnline()).thenReturn(true);
|
||||
when(sessions.active(active.player())).thenReturn(java.util.Optional.of(active));
|
||||
when(sessions.activeCharacter(active.character())).thenReturn(java.util.Optional.of(active));
|
||||
when(sessions.current(active)).thenReturn(true);
|
||||
when(sessions.acknowledgeProgress(eq(active), any(), any())).thenReturn(true);
|
||||
when(storage.find(original.id())).thenReturn(java.util.concurrent.CompletableFuture.completedFuture(java.util.Optional.of(original)));
|
||||
var failed = new java.util.concurrent.CompletableFuture<games.dmg.fantasystick.persistence.CharacterStorage.CombatAward>();
|
||||
failed.completeExceptionally(new java.io.IOException("simulated write failure"));
|
||||
@@ -257,13 +360,15 @@ class CombatExperienceNativeTest {
|
||||
service.damage(new EntityDamageByEntityEvent(attacker, target, DamageCause.ENTITY_ATTACK, damageSource, 1));
|
||||
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
|
||||
verify(storage, times(1)).awardCombatReward(eq(original.id()), any(), any(), any(), any(), any(), any(), any());
|
||||
assertEquals(1, failures.size());
|
||||
assertEquals(1, failures.size()); verify(attacker, never()).sendMessage(anyString());
|
||||
service.configure(service.rules().withHardPool(java.math.BigInteger.valueOf(25)));
|
||||
service.retryPending();
|
||||
verify(storage, times(2)).awardCombatReward(eq(original.id()), any(), any(), any(), any(), any(), any(), any());
|
||||
verify(attacker, times(1)).sendMessage(contains("character coins"));
|
||||
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
|
||||
service.retryPending();
|
||||
verify(storage, times(2)).awardCombatReward(eq(original.id()), any(), any(), any(), any(), any(), any(), any());
|
||||
verify(attacker, times(1)).sendMessage(contains("character coins"));
|
||||
service.close();
|
||||
}
|
||||
|
||||
|
||||
@@ -633,6 +633,110 @@ class EquipmentSessionEventsNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void crossWorldDefeatCloseRebindsProvedTailDeliveryWithOrWithoutArmor() throws Exception {
|
||||
for (boolean armored : new boolean[]{false, true}) try (var f = new Fixture()) {
|
||||
var a = f.actor;
|
||||
var overworld = org.mockito.Mockito.mock(org.bukkit.World.class);
|
||||
var end = org.mockito.Mockito.mock(org.bukkit.World.class);
|
||||
org.mockito.Mockito.when(overworld.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
org.mockito.Mockito.when(end.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
a.location = new org.bukkit.Location(overworld, 12, 70, -8, 45, 10);
|
||||
if (!armored) a.inventory.setArmorContents(new ItemStack[4]);
|
||||
var entry = a.location.clone(); f.core.activate(a);
|
||||
a.location = new org.bukkit.Location(end, 80, 64, 40);
|
||||
var closingView = a.view;
|
||||
a.teleport = () -> {
|
||||
f.dispatcher.accept(new org.bukkit.event.inventory.InventoryCloseEvent(closingView));
|
||||
a.view = null;
|
||||
};
|
||||
|
||||
f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.OUT_OF_WORLD));
|
||||
f.core.tick();
|
||||
|
||||
assertEquals(entry, a.location);
|
||||
assertEquals(Material.WRITTEN_BOOK, a.inventory.getItemInOffHand().getType());
|
||||
assertTrue(f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.orElseThrow().dead());
|
||||
assertNull(f.core.state(a).pending(), "Successful Tail delivery releases the completed receipt");
|
||||
var deadSheet = new games.dmg.fantasystick.items.CharacterSheets().create(a.record);
|
||||
a.inventory.setItemInOffHand(deadSheet); f.core.sessions.enter(a.player);
|
||||
f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS); f.core.tick();
|
||||
assertEquals("UNAVAILABLE_CHARACTER", f.core.notices.getLast().code(),
|
||||
"A completed defeat has no false persistence blocker and cannot reactivate");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void exactRetainedSheetUseCompletesOnlyNeverStartedSameLifetimeTailConversion() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var a = f.actor; f.core.activate(a);
|
||||
var retained = a.inventory.getItemInOffHand().clone();
|
||||
f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.GENERIC));
|
||||
var original = f.core.next();
|
||||
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType());
|
||||
assertNull(f.core.state(a).pending(), "Ordinary restoration must be acknowledged before retry");
|
||||
|
||||
f.core.sessions.enter(a.player);
|
||||
assertEquals(Material.WRITTEN_BOOK, a.inventory.getItemInOffHand().getType());
|
||||
var delivered = a.inventory.getItemInOffHand().clone();
|
||||
original.run(); original.run(); f.core.sessions.enter(a.player);
|
||||
assertEquals(delivered, a.inventory.getItemInOffHand());
|
||||
assertEquals(1, a.teleports, "Retry never replays defeat restoration or return teleport");
|
||||
|
||||
a.inventory.setItemInOffHand(retained); f.core.sessions.enter(a.player);
|
||||
f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS); f.core.tick();
|
||||
assertEquals("UNAVAILABLE_CHARACTER", f.core.notices.getLast().code());
|
||||
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType(),
|
||||
"The dead sheet cannot activate or create a second Tail after release");
|
||||
}
|
||||
|
||||
try (var f = new Fixture()) {
|
||||
var a = f.actor; f.core.activate(a);
|
||||
var copied = a.inventory.getItemInOffHand().clone();
|
||||
f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.GENERIC));
|
||||
var original = f.core.next();
|
||||
a.inventory.setItemInOffHand(copied);
|
||||
f.core.sessions.enter(a.player);
|
||||
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType());
|
||||
assertTrue(f.core.notices.getLast().message().contains("inspection"));
|
||||
original.run(); original.run();
|
||||
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType(),
|
||||
"An apparently identical replacement sheet is not process-local custody proof");
|
||||
assertEquals(1, f.core.failures.size());
|
||||
f.core.expectedFailure = IllegalStateException.class;
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectedOriginalTailCallbackRetainsNarrowSameLifetimeRetryAuthority() throws Exception {
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var writes = new java.util.concurrent.atomic.AtomicInteger();
|
||||
try (var f = new Fixture((source, target) -> {
|
||||
if (writes.incrementAndGet() == 2) {
|
||||
entered.countDown();
|
||||
try { if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new java.io.IOException("Gate timeout"); }
|
||||
catch (InterruptedException interrupted) { throw new java.io.IOException(interrupted); }
|
||||
}
|
||||
EquipmentSessionsNativeTest.move(source, target);
|
||||
})) {
|
||||
var a = f.actor; f.core.activate(a);
|
||||
try {
|
||||
f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.GENERIC));
|
||||
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertNull(f.core.state(a).pending());
|
||||
f.core.rejectSchedule = true;
|
||||
} finally { release.countDown(); }
|
||||
f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType());
|
||||
assertEquals(1, f.core.failures.size()); f.core.expectedFailure = IllegalStateException.class;
|
||||
|
||||
f.core.rejectSchedule = false;
|
||||
f.core.sessions.enter(a.player);
|
||||
assertEquals(Material.WRITTEN_BOOK, a.inventory.getItemInOffHand().getType());
|
||||
assertNull(f.core.state(a).pending());
|
||||
assertEquals(1, a.teleports);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void everyRegisteredDamageTypeUsesTheSameDeathBoundaryButOrdinaryAndPriorVetoesStayUntouched() throws Exception {
|
||||
int causes = 0;
|
||||
for (var type : org.bukkit.Registry.DAMAGE_TYPE) {
|
||||
@@ -717,6 +821,11 @@ class EquipmentSessionEventsNativeTest {
|
||||
assertEquals(1, f.core.failures.size()); assertInstanceOf(IllegalStateException.class, f.core.failures.getFirst().cause());
|
||||
f.core.expectedFailure = IllegalStateException.class;
|
||||
a.online = true; f.core.sessions.arrive(a.player); f.core.sessions.recover(a.player);
|
||||
if (scenario == 3) {
|
||||
f.core.sessions.enter(a.player);
|
||||
assertEquals("DEFEAT_INSPECTION_REQUIRED", f.core.notices.getLast().code(),
|
||||
"Reconnect loses same-lifetime Tail retry authority");
|
||||
}
|
||||
assertArrayEquals(before, a.snapshot()); assertTrue(f.core.tasks.isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -762,7 +871,7 @@ class EquipmentSessionEventsNativeTest {
|
||||
assertTrue(event.isCancelled()); assertArrayEquals(before, a.snapshot());
|
||||
assertTrue(a.marker().endsWith(exception ? "|RESTORING" : "|PACKED"));
|
||||
assertNotNull(f.core.state(a).pending()); assertEquals(a.normalMaximum, a.health);
|
||||
var callback = f.core.next(); callback.run(); callback.run();
|
||||
assertTrue(f.core.tasks.isEmpty(), "Unrestored equipment never queues a Tail write");
|
||||
assertArrayEquals(before, a.snapshot()); assertNotNull(f.core.state(a).pending());
|
||||
assertTrue(f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().dead());
|
||||
assertEquals(exception ? 2 : 1, f.core.failures.size()); f.core.expectedFailure = IllegalStateException.class;
|
||||
@@ -790,6 +899,10 @@ class EquipmentSessionEventsNativeTest {
|
||||
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertNull(f.core.state(a).pending(), "Gear receipt cleanup acknowledged first");
|
||||
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType());
|
||||
f.core.sessions.enter(a.player);
|
||||
assertEquals("DEFEAT_PENDING", f.core.notices.getLast().code());
|
||||
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType(),
|
||||
"Pending death persistence grants neither activation nor a Tail");
|
||||
competingRead.run();
|
||||
assertEquals("CHARACTER_BUSY", f.core.notices.getLast().code());
|
||||
assertTrue(f.core.sessions.active(copy.id).isEmpty()); assertTrue(f.core.tasks.isEmpty());
|
||||
@@ -824,7 +937,7 @@ class EquipmentSessionEventsNativeTest {
|
||||
assertEquals(Material.DIAMOND_SWORD, a.inventory.getItem(0).getType());
|
||||
assertEquals(sheet, a.inventory.getItemInOffHand());
|
||||
} finally { release.countDown(); }
|
||||
if (route == 0) f.core.tick();
|
||||
if (route == 0) assertTrue(f.core.tasks.isEmpty(), "Quit invalidates delivery before any Tail write");
|
||||
f.core.sessions.close(); f.core.sessions.drained().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
var dead = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(a.record.id()).orElseThrow();
|
||||
assertTrue(dead.dead()); assertEquals(sheet, a.inventory.getItemInOffHand()); assertEquals(2, writes.get());
|
||||
@@ -932,6 +1045,11 @@ class EquipmentSessionEventsNativeTest {
|
||||
callback.run(); a.offhandMutation = () -> {}; a.beforeOffhandMutation = () -> {};
|
||||
assertEquals(afterWrite ? Material.WRITTEN_BOOK : Material.PAPER, a.inventory.getItemInOffHand().getType());
|
||||
var current = a.snapshot(); callback.run(); f.core.sessions.recover(a.player);
|
||||
if (!afterWrite) {
|
||||
f.core.sessions.enter(a.player);
|
||||
assertEquals("DEFEAT_INSPECTION_REQUIRED", f.core.notices.getLast().code(),
|
||||
"A possible native write never authorizes retained-sheet retry");
|
||||
}
|
||||
assertArrayEquals(current, a.snapshot()); assertTrue(f.core.tasks.isEmpty());
|
||||
assertEquals(2, f.core.failures.size()); f.core.expectedFailure = IllegalStateException.class;
|
||||
assertTrue(f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().dead());
|
||||
@@ -942,7 +1060,8 @@ class EquipmentSessionEventsNativeTest {
|
||||
try (var f = new Fixture()) {
|
||||
var a = f.actor; f.core.activate(a); var control = a.inventory.getItem(0).clone();
|
||||
a.inventory.setItem(0, new ItemStack(Material.EMERALD));
|
||||
f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.GENERIC)); f.core.tick();
|
||||
f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.GENERIC));
|
||||
assertTrue(f.core.tasks.isEmpty(), "Failed restoration queues no Tail write");
|
||||
assertNotNull(f.core.state(a).pending()); assertTrue(a.marker().endsWith("|PACKED"));
|
||||
var dead = f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
|
||||
a.inventory.setItem(20, a.inventory.getItem(0)); a.inventory.setItem(0, control);
|
||||
|
||||
@@ -285,7 +285,8 @@ class WholeCharacterTransferNativeTest {
|
||||
}
|
||||
if (ending == Ending.DEFEAT) {
|
||||
if (restoration != Restoration.SUCCESS) h.f.expectedFailure = IllegalStateException.class;
|
||||
h.f.tick(); // Actual asynchronous death acknowledgment / one-use Tail attempt.
|
||||
if (restoration == Restoration.SUCCESS)
|
||||
h.f.tick(); // Restoration acknowledgment permits the one-use Tail attempt.
|
||||
var dead = h.saved(record.id()); assertTrue(dead.dead()); assertProgress(renamed, dead);
|
||||
assertEquals(0, dead.health().signum());
|
||||
assertEquals(restoration == Restoration.SUCCESS ? Material.WRITTEN_BOOK : Material.PAPER,
|
||||
|
||||
@@ -135,6 +135,54 @@ class CharacterStorageTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void restrictedSpacingAtomicallyDiscardsCoinsWithoutDisablingOtherZeroXpCoinAwards() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var progression = new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.ZERO, BigInteger.TEN, 30);
|
||||
var rewards = games.dmg.fantasystick.progression.CombatRewardRules.defaults();
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(progression);
|
||||
var live = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
|
||||
base.experience(), base.unspentPoints(), base.health(), base.learned(), base.loadout(), base.passives(),
|
||||
base.cooldowns(), base.restrictedKills(), false, null, null, BigInteger.valueOf(20), true, java.util.Set.of());
|
||||
var world = UUID.randomUUID();
|
||||
var first = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(world,
|
||||
java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
|
||||
var near = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(world,
|
||||
java.math.BigDecimal.ONE, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
storage.create(live).join();
|
||||
var eligible = storage.awardCombatReward(live.id(), live.revision(), UUID.randomUUID(), BigInteger.TEN,
|
||||
BigInteger.valueOf(6), progression, rewards, first).join();
|
||||
assertEquals(CharacterStorage.CombatAwardStatus.APPLIED, eligible.status());
|
||||
assertEquals(BigInteger.valueOf(26), eligible.record().orElseThrow().coins());
|
||||
|
||||
var blockedId = UUID.randomUUID();
|
||||
var blocked = storage.awardCombatReward(live.id(), eligible.record().orElseThrow().revision(), blockedId,
|
||||
BigInteger.TEN, BigInteger.valueOf(7), progression, rewards, near).join();
|
||||
assertEquals(CharacterStorage.CombatAwardStatus.SPACING_BLOCKED, blocked.status());
|
||||
var afterBlocked = blocked.record().orElseThrow();
|
||||
assertEquals(eligible.record().orElseThrow().experience(), afterBlocked.experience());
|
||||
assertEquals(BigInteger.valueOf(26), afterBlocked.coins(), "Spacing discards rather than escrows the allocated coin share");
|
||||
assertEquals(near, afterBlocked.restrictedKills().get(world), "Blocked awards advance the persisted spacing chain");
|
||||
assertTrue(afterBlocked.rewardReceipts().contains(blockedId));
|
||||
assertEquals(afterBlocked, storage.awardCombatReward(live.id(), afterBlocked.revision(), blockedId,
|
||||
BigInteger.TEN, BigInteger.valueOf(7), progression, rewards, near).join().record().orElseThrow());
|
||||
|
||||
var zeroXp = storage.awardCombatReward(live.id(), afterBlocked.revision(), UUID.randomUUID(), BigInteger.ZERO,
|
||||
BigInteger.valueOf(4), progression, rewards, null).join();
|
||||
assertEquals(CharacterStorage.CombatAwardStatus.APPLIED, zeroXp.status());
|
||||
assertEquals(BigInteger.valueOf(30), zeroXp.record().orElseThrow().coins(),
|
||||
"A non-spacing zero-XP result remains coin-eligible");
|
||||
|
||||
var cappedRules = new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.ZERO, BigInteger.TEN, 1);
|
||||
var capped = storage.awardCombatReward(live.id(), zeroXp.record().orElseThrow().revision(), UUID.randomUUID(),
|
||||
BigInteger.TEN, BigInteger.valueOf(5), cappedRules, rewards, null).join();
|
||||
assertEquals(CharacterStorage.CombatAwardStatus.AT_CAP, capped.status());
|
||||
assertEquals(BigInteger.valueOf(35), capped.record().orElseThrow().coins(),
|
||||
"Maximum-level status alone must not suppress coins");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void stableCoinDebitIsAtomicIdempotentAndNeverOverdraws() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
|
||||
Reference in new issue
Block a user