Compare commits

...
1 Commits
Author SHA1 Message Date
dmg f8fed8998b fix(rewards): apply mob spacing to coins
Release / release (push) Successful in 11m12s
2026-09-23 08:35:39 -04:00
4 changed files with 158 additions and 4 deletions

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);
});
@@ -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();
}
@@ -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);