feat(events): retain acknowledged coin owner audit intents
Release / release (push) Failing after 11m8s

This commit is contained in:
dmg committed 2026-10-02 09:02:02 -04:00
1 parent 6fc8c5d9eb
commit 4984ad26e7
4 files changed
+450 -6

No files matched your search

@@ -188,6 +188,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.events.CombatRewardEventBridge combatRewardEvents;
private java.util.concurrent.CompletionStage<Void> combatRewardAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
private java.util.concurrent.CompletionStage<Void> coinAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.SessionEventBridge sessionAuditEvents;
private games.dmg.fantasystick.events.NutritionEventBridge nutritionAuditEvents;
private java.util.concurrent.CompletionStage<Void> nutritionAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
@@ -899,7 +900,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
eventLog::emit, Clock.systemUTC()));
combatRewardEvents = new games.dmg.fantasystick.events.CombatRewardEventBridge(eventLog::emit,Clock.systemUTC(),getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING,"Combat reward event intent failed",failure));combatExperience.attachEvents(combatRewardEvents);
coinEvents = new games.dmg.fantasystick.events.CoinEventBridge(eventLog::emit,Clock.systemUTC());characters.attachCoinEvents(coinEvents);admin.attachCoinEvents(coinEvents);
coinEvents = new games.dmg.fantasystick.events.CoinEventBridge(eventLog::emit,Clock.systemUTC(),getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING,"Coin event intent failed",failure));characters.attachCoinEvents(coinEvents);admin.attachCoinEvents(coinEvents);
sessionAuditEvents = new games.dmg.fantasystick.events.SessionEventBridge(eventLog::emit,Clock.systemUTC());sessions.attachSessionEvents(sessionAuditEvents);
nutritionAuditEvents = new games.dmg.fantasystick.events.NutritionEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
@@ -1069,6 +1071,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (failure != null) getLogger().log(Level.WARNING, "Previous passive potion event drain failed", failure);
if (replayPassivePotionEvents != null) replayPassivePotionEvents.replay();
});
var replayCoinEvents = coinEvents;
coinAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous coin event drain failed", failure);
if (replayCoinEvents != null) replayCoinEvents.replay();
});
var replayCombatRewardEvents = combatRewardEvents;
combatRewardAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous combat reward event drain failed", failure);
@@ -1167,6 +1174,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingPartyCapacityEvents = partyCapacityEvents; partyCapacityEvents = null;
var closingSheetPackEvents = sheetResourcePackEvents; sheetResourcePackEvents = null;
var closingGrantEvents = adminExperienceEvents; adminExperienceEvents = null;
var closingCoinEvents = coinEvents;
var closingCombatRewardEvents = combatRewardEvents;
var closingHealthStealEvents = healthStealEvents; healthStealEvents = null;
var closingPassivePotionEvents = passivePotionEvents; passivePotionEvents = null;
@@ -1536,6 +1544,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
closingPassivePotionEvents.close();
passivePotionAuditDrain = closingPassivePotionEvents.drained();
});
if (closingCoinEvents != null) cleanup("coin event intent drain", () -> {
var storageDrained = closingCharacters == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
coinAuditDrain = storageDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Character storage drain failed", failure);
closingCoinEvents.close(); return null;
}).thenCompose(ignored -> closingCoinEvents.drained());
});
if (closingCombatRewardEvents != null) cleanup("combat reward event intent drain", () -> {
var storageDrained = closingCharacters == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
@@ -12,15 +12,25 @@ import java.util.UUID;
import java.util.function.Consumer;
/** Failure-isolated character coin initialization, final-death loss and configuration records. */
public final class CoinEventBridge {
public final class CoinEventBridge implements AutoCloseable {
private final Consumer<EventInput> sink;private final Clock clock;
public CoinEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
private final FeatureEventOutbox outbox;
public CoinEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);outbox=null;}
public CoinEventBridge(java.util.function.Function<EventInput,java.util.concurrent.CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock,java.nio.file.Path data,Consumer<Throwable> failures){
this.sink=event->sink.apply(event);this.clock=Objects.requireNonNull(clock);
outbox=new FeatureEventOutbox(data,"coin-event-intents",java.util.Set.of("character.coins_initialized","character.coins_lost","config.read","config.changed"),sink,failures);
}
public void replay(){if(outbox!=null)outbox.replay();}
public java.util.concurrent.CompletionStage<Void> drained(){return outbox==null?java.util.concurrent.CompletableFuture.completedFuture(null):outbox.drained();}
@Override public void close(){if(outbox!=null)outbox.close();}
private void publish(EventInput event){if(outbox!=null)outbox.accepted(event);else sink.accept(event);}
public void initialized(CharacterRecord record,String source,CoinRules rules){var operation=stable(record.id(),"coins-initialized");var data=new LinkedHashMap<String,Object>();data.put("operation_id",operation.toString());data.put("character_id",record.id().toString());data.put("character_name",record.name());data.put("source",source);data.put("configured_minimum",Long.toString(rules.startingMin()));data.put("configured_maximum",Long.toString(rules.startingMax()));data.put("balance",record.coins().toString());data.put("revision",record.revision().toString());data.put("outcome","acknowledged");emit(operation,"character.coins_initialized",data);}
public void lost(CharacterRecord before,CharacterRecord after,UUID death,Instant diedAt){var data=new LinkedHashMap<String,Object>();data.put("operation_id",death.toString());data.put("death_id",death.toString());data.put("character_id",after.id().toString());data.put("character_name",after.name());data.put("reason","player_attributed_final_death");data.put("balance_before",before.coins().toString());data.put("balance_after",after.coins().toString());data.put("death_bound_escrow",after.pvpCoinEscrow().toString());data.put("before_revision",before.revision().toString());data.put("after_revision",after.revision().toString());data.put("final_death_at",diedAt.toString());data.put("outcome","acknowledged");emit(death,"character.coins_lost",data);}
public void configRead(String actorKind,UUID player,String name,String key,String value){config("config.read",actorKind,player,name,key,null,value);}
public void configChanged(String actorKind,UUID player,String name,String key,String before,String after){config("config.changed",actorKind,player,name,key,before,after);}
private void config(String type,String actorKind,UUID player,String name,String key,String before,String after){var action=UUID.randomUUID();var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(name!=null)data.put("actor_player_name",name);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application",key.startsWith("starting-")?"future_character_initialization":"future_mob_defeats");data.put("outcome","acknowledged");try{sink.accept(new EventInput(id(action,type),action,clock.instant(),type,player==null?null:new EventInput.Context(player,name,null,null,null,null),Map.copyOf(data)));}catch(RuntimeException ignored){}}
private void emit(UUID action,String type,Map<String,Object> data){try{sink.accept(new EventInput(id(action,type),action,clock.instant(),type,null,Map.copyOf(data)));}catch(RuntimeException ignored){}}
private void config(String type,String actorKind,UUID player,String name,String key,String before,String after){var action=UUID.randomUUID();var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(name!=null)data.put("actor_player_name",name);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application",key.startsWith("starting-")?"future_character_initialization":"future_mob_defeats");data.put("outcome","acknowledged");try{publish(new EventInput(id(action,type),action,clock.instant(),type,player==null?null:new EventInput.Context(player,name,null,null,null,null),Map.copyOf(data)));}catch(RuntimeException ignored){}}
private void emit(UUID action,String type,Map<String,Object> data){try{publish(new EventInput(id(action,type),action,clock.instant(),type,null,Map.copyOf(data)));}catch(RuntimeException ignored){}}
private static UUID stable(UUID character,String kind){return UUID.nameUUIDFromBytes(("fantasystick:coin:"+character+":"+kind).getBytes(StandardCharsets.UTF_8));}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:coin:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
}
@@ -1931,7 +1931,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void rareConfiguration(CommandSender sender,String[] args){String key=args[1],before=rareSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;boolean phoenix=key.contains("phoenix-down");if(args.length==2){if(phoenix&&downedEvents!=null)downedEvents.configRead(actorKind,actorPlayer,actorName,key,before);else if((potionConfig(key)||key.endsWith("-reskill")||key.startsWith("rare-stock-reskill-")||key.endsWith("-magic-core")||key.startsWith("rare-stock-magic-core-"))&&rareAuditEvents!=null)rareAuditEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+(key.endsWith("-healing-percent")||key.equals(games.dmg.fantasystick.persistence.RareSettings.PHOENIX_PROTECTION)?" (applies to future accepted uses)":" (applies to the next rare restock only)"));return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RareSettings.Update> operation;try{operation=rareSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Rare setting is invalid; percentages are 0–100, values are nonnegative, and stock minima cannot exceed maxima.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&rareSettings.publish(saved);if(applied&&phoenix&&downedEvents!=null)downedEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());else if(applied&&(potionConfig(key)||key.endsWith("-reskill")||key.startsWith("rare-stock-reskill-")||key.endsWith("-magic-core")||key.startsWith("rare-stock-magic-core-"))&&rareAuditEvents!=null)rareAuditEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Rare setting save failed; prior effective state is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+rareSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+"; current catalog and accepted uses are unchanged.");});});}
private void downedConfiguration(CommandSender sender,String[] args){String key=args[1],before=downedSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;if(args.length==2){if(downedEvents!=null)downedEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+" (applies to future downed outcomes only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.DownedSettings.Update> operation;try{operation=downedSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Downed setting is invalid; timeout must be positive and protection may be zero, both at most 365 days.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&downedSettings.publish(saved);if(applied&&downedEvents!=null)downedEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Downed setting save failed; prior effective state is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+downedSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+"; existing deadlines are unchanged.");});});}
private void coinConfiguration(CommandSender sender,String[] args){String key=args[1],before=coinSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;if(args.length==2){if(coinEvents!=null)coinEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+" (applies to future creation or defeats only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.CoinSettings.Update> operation;try{operation=coinSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Coin setting is invalid; ranges must be ordered whole numbers from 0 through 2147483647.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&coinSettings.publish(saved);if(applied&&coinEvents!=null)coinEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Coin configuration save failed; effective ranges are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+coinSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future creation or defeats; existing balances and captured rewards are unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void coinConfiguration(CommandSender sender,String[] args){String key=args[1],before=coinSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;if(args.length==2){if(coinEvents!=null)coinEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+" (applies to future creation or defeats only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.CoinSettings.Update> operation;try{operation=coinSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Coin setting is invalid; ranges must be ordered whole numbers from 0 through 2147483647.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;String publishedBefore=coinSettings.value(key);boolean applied=failure==null&&coinSettings.publish(saved);if(applied&&coinEvents!=null)coinEvents.configChanged(actorKind,actorPlayer,actorName,key,publishedBefore,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Coin configuration save failed; effective ranges are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+coinSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future creation or defeats; existing balances and captured rewards are unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void hardMobXpConfiguration(CommandSender sender, String[] args) {
String key = games.dmg.fantasystick.persistence.HardMobXpSettings.KEY;
@@ -0,0 +1,418 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.google.gson.JsonObject;
import games.dmg.fantasystick.events.LocalEventLog;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CoinEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void rejectedAdminIssuedInitializationReplaysOriginalRollWithoutIssuingOrRerollingAgain() throws Exception {
Files.writeString(data.resolve("config.yml"), "starting-coins-min: 4\nstarting-coins-max: 4\n");
var intent = new Properties(); CharacterRecord issued;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(target.core.id, target.player);
var sender = new EquipmentAdminCommandNativeTest.Sender();
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"give", target.accountName});
ReskillEventsNativeTest.await(f, () -> sender.messages.stream()
.anyMatch(message -> message.contains("Issued one new character sheet")));
var sheets = new CharacterSheets();
var delivered = java.util.stream.IntStream.range(0, 36)
.filter(slot -> sheets.identify(target.core.inventory.getItem(slot)).isPresent()
&& !sheets.identify(target.core.inventory.getItem(slot)).orElseThrow().equals(target.core.record.id()))
.toArray();
assertEquals(1, delivered.length);
var id = sheets.identify(target.core.inventory.getItem(delivered[0])).orElseThrow();
issued = storage(f).find(id).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals(BigInteger.valueOf(4), issued.coins()); assertTrue(issued.coinsInitialized());
f.plugin.setEnabled(false); f.awaitWorkers();
var folder = data.resolve("coin-event-intents");
assertTrue(Files.isDirectory(folder), "An issued saved roll must retain its rejected audit intent");
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "coinAuditDrain"))
.toCompletableFuture().get(5, TimeUnit.SECONDS);
try (var files = Files.list(folder)) {
var pending = files.filter(p -> p.getFileName().toString().endsWith(".pending")).toList();
assertEquals(1, pending.size());
try (var input = Files.newInputStream(pending.getFirst())) { intent.load(input); }
}
assertTrue(rows("character.coins_initialized").isEmpty());
var inventory = java.util.Arrays.stream(target.core.inventory.getContents())
.map(item -> item == null ? null : item.clone()).toArray(org.bukkit.inventory.ItemStack[]::new);
f.players.clear(); clearInvocations(target.player);
f.plugin.setEnabled(true); ready(f);
ReskillEventsNativeTest.await(f, () -> rows("character.coins_initialized").size() == 1);
assertEquals(issued, storage(f).find(id).get().orElseThrow(), "Replay cannot allocate another roll or revision");
assertArrayEquals(inventory, target.core.inventory.getContents());
verify(target.player, never()).setHealth(anyDouble());
f.plugin.setEnabled(false); f.awaitWorkers();
}
var records = rows("character.coins_initialized"); assertEquals(1, records.size());
var row = records.getFirst(); var values = row.getAsJsonObject("data");
assertEquals(intent.getProperty("id"), row.get("id").getAsString());
assertEquals(intent.getProperty("time"), row.get("time").getAsString());
assertEquals(intent.getProperty("action"), values.get("action_id").getAsString());
assertEquals(issued.id().toString(), values.get("character_id").getAsString());
assertEquals("admin_issue", values.get("source").getAsString());
assertEquals("4", values.get("configured_minimum").getAsString());
assertEquals("4", values.get("configured_maximum").getAsString());
assertEquals("4", values.get("balance").getAsString());
assertEquals(issued.revision().toString(), values.get("revision").getAsString());
assertEquals("acknowledged", values.get("outcome").getAsString());
}
@Test void queuedCoinConfigurationRecordsTheActualPublishedBeforeValue() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f);
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "starting-coins-min", "2"});
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "starting-coins-min", "3"});
ReskillEventsNativeTest.await(f, () -> sender.messages.stream()
.filter(message -> message.startsWith("Saved starting-coins-min=")).count() == 2);
ReskillEventsNativeTest.await(f, () -> rows("config.changed").size() == 2);
var settings = (games.dmg.fantasystick.persistence.CoinSettings)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "coinSettings");
assertEquals("3", settings.value("starting-coins-min"));
var changes = rows("config.changed").stream().map(row -> row.getAsJsonObject("data"))
.collect(java.util.stream.Collectors.toMap(value -> value.get("after").getAsString(), value -> value));
assertEquals("1", changes.get("2").get("before").getAsString());
assertEquals("2", changes.get("3").get("before").getAsString(),
"The second acknowledged configuration change starts at the first published value, not its request-time snapshot");
assertTrue(changes.values().stream().allMatch(value -> value.get("application").getAsString()
.equals("future_character_initialization")));
assertEquals(2, rows("config.changed").stream().map(row -> row.get("id").getAsString()).distinct().count());
}
}
@Test void playerFinalDeathLosesExactBalanceWithoutEligibleAttackerAndReplaysOnlyAudit() throws Exception {
var balance = BigInteger.ONE.shiftLeft(200); var pending = new Properties();
CharacterRecord finalized;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f);
var attacker = new FantasyStickLifecycleNativeTest.Actor(data);
var victim = new FantasyStickLifecycleNativeTest.Actor(data);
doReturn(org.bukkit.entity.EntityType.PLAYER).when(attacker.player).getType();
doReturn(org.bukkit.entity.EntityType.PLAYER).when(victim.player).getType();
doReturn("CoinAttacker").when(attacker.player).getName();
doReturn("CoinVictim").when(victim.player).getName();
seedCoins(victim, balance, true);
f.players.put(attacker.core.id, attacker.player); f.players.put(victim.core.id, victim.player);
f.activate(attacker); f.activate(victim);
var originalAttacker = storage(f).find(attacker.core.record.id()).get().orElseThrow();
var source = mock(org.bukkit.damage.DamageSource.class);
when(source.getDamageType()).thenReturn(org.bukkit.damage.DamageType.PLAYER_ATTACK);
when(source.getCausingEntity()).thenReturn(attacker.player); when(source.getDirectEntity()).thenReturn(attacker.player);
var damage = new org.bukkit.event.entity.EntityDamageByEntityEvent(attacker.player, victim.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 4);
doReturn(damage).when(victim.player).getLastDamageCause();
doReturn(null).when(attacker.player).getLastDamageCause();
f.dispatcher.accept(damage); assertFalse(damage.isCancelled());
victim.core.health = 0;
var lethal = new org.bukkit.event.entity.PlayerDeathEvent(victim.player, source,
new java.util.ArrayList<>(), 0, net.kyori.adventure.text.Component.empty(), true);
f.dispatcher.accept(lethal); assertTrue(lethal.isCancelled());
var downed = (games.dmg.fantasystick.session.DownedDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "downed");
ReskillEventsNativeTest.await(f, () -> !downed.all().isEmpty() && !victim.inventories.isEmpty());
var episode = downed.all().getFirst();
var waiting = storage(f).find(victim.core.record.id()).get().orElseThrow();
assertFalse(waiting.dead()); assertEquals(balance, waiting.coins());
assertEquals(BigInteger.ZERO, waiting.pvpCoinEscrow());
assertTrue(rows("character.coins_lost").isEmpty()); assertTrue(rows("combat.reward_resolved").isEmpty());
PartyCombatNativeTest.exit(f, attacker);
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
DownedSessionNativeTest.acceptDeath(f, victim); DownedSessionNativeTest.awaitTail(f, victim);
finalized = storage(f).find(victim.core.record.id()).get().orElseThrow();
assertTrue(finalized.dead()); assertEquals(BigInteger.ZERO, finalized.coins());
assertEquals(episode.id(), finalized.deathId());
assertEquals(originalAttacker.experience(), storage(f).find(attacker.core.record.id()).get().orElseThrow().experience());
assertEquals(originalAttacker.coins(), storage(f).find(attacker.core.record.id()).get().orElseThrow().coins());
assertTrue(rows("combat.reward_resolved").isEmpty(), "Inactive attackers cannot receive a new final-death share");
f.dispatcher.accept(lethal); CombatExperienceNativeTest.settle(f);
assertEquals(finalized, storage(f).find(finalized.id()).get().orElseThrow(), "Duplicate native outcome cannot lose coins again");
f.plugin.setEnabled(false); f.awaitWorkers(); coinDrain(f);
try (var files = Files.list(data.resolve("coin-event-intents"))) {
var intents = files.filter(path -> path.getFileName().toString().endsWith(".pending")).toList();
assertEquals(1, intents.size());
try (var input = Files.newInputStream(intents.getFirst())) { pending.load(input); }
}
assertTrue(rows("character.coins_lost").isEmpty());
var tail = victim.core.inventory.getItemInOffHand().clone();
f.players.clear(); clearInvocations(victim.player, attacker.player);
f.plugin.setEnabled(true); ready(f);
ReskillEventsNativeTest.await(f, () -> rows("character.coins_lost").size() == 1);
assertEquals(finalized, storage(f).find(finalized.id()).get().orElseThrow());
assertEquals(tail, victim.core.inventory.getItemInOffHand());
verify(victim.player, never()).setHealth(anyDouble()); verify(attacker.player, never()).setHealth(anyDouble());
assertTrue(rows("combat.reward_resolved").isEmpty());
var row = rows("character.coins_lost").getFirst(); var values = row.getAsJsonObject("data");
assertEquals(pending.getProperty("id"), row.get("id").getAsString());
assertEquals(pending.getProperty("time"), row.get("time").getAsString());
assertEquals(pending.getProperty("action"), values.get("action_id").getAsString());
assertEquals(episode.id().toString(), values.get("death_id").getAsString());
assertEquals(finalized.id().toString(), values.get("character_id").getAsString());
assertEquals(balance.toString(), values.get("balance_before").getAsString());
assertEquals("0", values.get("balance_after").getAsString());
assertEquals(finalized.pvpCoinEscrow().toString(), values.get("death_bound_escrow").getAsString());
assertEquals(finalized.revision().toString(), values.get("after_revision").getAsString());
assertEquals(finalized.diedAt().toString(), values.get("final_death_at").getAsString());
assertEquals("player_attributed_final_death", values.get("reason").getAsString());
f.players.put(attacker.core.id, attacker.player); f.players.put(victim.core.id, victim.player);
doReturn(true).when(attacker.player).hasPermission("fantasystick.admin");
f.adminCommand.execute(attacker.player, "fstickadmin", new String[]{"revive", "CoinVictim"});
ReskillEventsNativeTest.await(f, () -> !attacker.inventories.isEmpty());
var view = mock(org.bukkit.inventory.InventoryView.class);
when(view.getTopInventory()).thenReturn(attacker.inventories.getLast());
when(view.getBottomInventory()).thenReturn(attacker.core.inventory); when(view.getPlayer()).thenReturn(attacker.player);
when(view.getType()).thenReturn(org.bukkit.event.inventory.InventoryType.CHEST);
var selection = new org.bukkit.event.inventory.InventoryClickEvent(view,
org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER, 0,
org.bukkit.event.inventory.ClickType.LEFT, org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);
f.dispatcher.accept(selection); assertTrue(selection.isCancelled());
ReskillEventsNativeTest.await(f, () -> !storage(f).find(victim.core.record.id()).join().orElseThrow().dead()
&& new CharacterSheets().identify(victim.core.inventory.getItemInOffHand()).isPresent());
var revived = storage(f).find(victim.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.ZERO, revived.coins()); assertTrue(revived.coinsInitialized());
assertTrue(rows("character.coins_initialized").isEmpty(), "A zero-balance revived character must not reroll");
assertEquals(1, rows("character.coins_lost").size()); assertTrue(rows("combat.reward_resolved").isEmpty());
f.plugin.setEnabled(false); f.awaitWorkers(); coinDrain(f);
}
assertEquals(1, rows("character.coins_lost").size());
}
@Test void legacyMigrationRequiresSaveThenReachesRealBookOnceWhileInitializedZeroNeverRerolls() throws Exception {
Files.writeString(data.resolve("config.yml"), "starting-coins-min: 4\nstarting-coins-max: 4\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f);
var legacy = new FantasyStickLifecycleNativeTest.Actor(data); seedCoins(legacy, BigInteger.ZERO, false);
var zero = new FantasyStickLifecycleNativeTest.Actor(data); seedCoins(zero, BigInteger.ZERO, true);
f.players.put(legacy.core.id, legacy.player); f.players.put(zero.core.id, zero.player);
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"),
java.nio.file.StandardOpenOption.WRITE); var lock = channel.lock()) {
assertTrue(lock.isValid());
assertThrows(java.util.concurrent.ExecutionException.class,
() -> storage(f).find(legacy.core.record.id()).get(5, TimeUnit.SECONDS));
assertTrue(rows("character.coins_initialized").isEmpty(), "Failed migration cannot claim a saved roll");
}
assertFalse(new games.dmg.fantasystick.persistence.YamlCharacterRepository(data)
.load(legacy.core.record.id()).orElseThrow().coinsInitialized());
f.activate(legacy); f.activate(zero);
var migrated = storage(f).find(legacy.core.record.id()).get().orElseThrow();
assertTrue(migrated.coinsInitialized()); assertEquals(BigInteger.valueOf(4), migrated.coins());
assertEquals(BigInteger.ZERO, storage(f).find(zero.core.record.id()).get().orElseThrow().coins());
PartyBookNativeTest.open(f, legacy); assertTrue(PartyBookNativeTest.text(legacy).contains("Coins: 4"));
PartyBookNativeTest.open(f, zero); assertTrue(PartyBookNativeTest.text(zero).contains("Coins: 0"));
ReskillEventsNativeTest.await(f, () -> rows("character.coins_initialized").size() == 1);
var row = rows("character.coins_initialized").getFirst(); var values = row.getAsJsonObject("data");
assertEquals("legacy_migration", values.get("source").getAsString());
assertEquals(migrated.id().toString(), values.get("character_id").getAsString());
assertEquals("4", values.get("balance").getAsString());
assertEquals("4", values.get("configured_minimum").getAsString());
assertEquals("4", values.get("configured_maximum").getAsString());
assertEquals(migrated.revision().toString(), values.get("revision").getAsString());
PartyBookNativeTest.open(f, legacy); PartyBookNativeTest.open(f, zero);
PartyCombatNativeTest.exit(f, legacy); PartyCombatNativeTest.exit(f, zero);
var retainedLegacy = storage(f).find(migrated.id()).get().orElseThrow();
var retainedZero = storage(f).find(zero.core.record.id()).get().orElseThrow();
f.plugin.setEnabled(false); f.awaitWorkers(); coinDrain(f); f.players.clear();
f.plugin.setEnabled(true); ready(f);
assertEquals(retainedLegacy, storage(f).find(migrated.id()).get().orElseThrow());
assertEquals(retainedZero, storage(f).find(zero.core.record.id()).get().orElseThrow());
f.plugin.setEnabled(false); f.awaitWorkers(); coinDrain(f);
assertEquals(List.of(row), rows("character.coins_initialized"), "Book inspection and reload cannot duplicate initialization");
}
}
@Test void allSixRegisteredCoinReadsAndCommittedChangesRemainIndependentOfDenialsAndSaveFailure() throws Exception {
Files.writeString(data.resolve("config.yml"), "unrelated-marker: retained\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f);
var sender = new EquipmentAdminCommandNativeTest.Sender();
var settings = (games.dmg.fantasystick.persistence.CoinSettings)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "coinSettings");
var keys = games.dmg.fantasystick.persistence.CoinSettings.keys();
var expected = keys.stream().collect(java.util.stream.Collectors.toMap(key -> key, settings::value));
for (var key : keys) f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", key});
ReskillEventsNativeTest.await(f, () -> rows("config.read").size() == 6);
for (var row : rows("config.read")) {
var values = row.getAsJsonObject("data"); var key = values.get("key").getAsString();
assertEquals(expected.get(key), values.get("after").getAsString());
assertEquals(key.startsWith("starting-") ? "future_character_initialization" : "future_mob_defeats",
values.get("application").getAsString());
assertEquals("console", values.get("actor_kind").getAsString());
}
sender.allowed = false;
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "starting-coins-min"});
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "starting-coins-min", "2"});
sender.allowed = true;
for (var invalid : List.of("-1", "7", "2.5", "2147483648", "bad"))
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "starting-coins-min", invalid});
assertEquals("1", settings.value("starting-coins-min"));
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.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "starting-coins-min", "2"});
ReskillEventsNativeTest.await(f, () -> sender.messages.contains("Coin configuration save failed; effective ranges are unchanged."));
assertEquals("1", settings.value("starting-coins-min")); assertTrue(rows("config.changed").isEmpty());
}
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "starting-coins-min", "2"});
ReskillEventsNativeTest.await(f, () -> rows("config.changed").size() == 1);
var change = rows("config.changed").getFirst().getAsJsonObject("data");
assertEquals("1", change.get("before").getAsString()); assertEquals("2", change.get("after").getAsString());
assertEquals("2", settings.value("starting-coins-min"));
var saved = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(data.resolve("config.yml").toFile());
assertEquals("2", saved.getString("starting-coins-min")); assertEquals("retained", saved.getString("unrelated-marker"));
f.plugin.setEnabled(false); f.awaitWorkers(); coinDrain(f);
}
assertEquals(6, rows("config.read").size()); assertEquals(1, rows("config.changed").size());
}
@Test void registeredCraftPublishesOnlyClaimedRollsNotCancelledStaleOrFailedPreparations() throws Exception {
Files.writeString(data.resolve("config.yml"), "starting-coins-min: 4\nstarting-coins-max: 4\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f);
var recipe = f.recipes.get(CharacterSheets.RECIPE);
var cancelled = new ManualCraftingControllerNativeTest.Actor(2, 2);
var cancelledEvent = new org.bukkit.event.inventory.CraftItemEvent(recipe, cancelled.fixture.view,
org.bukkit.event.inventory.InventoryType.SlotType.RESULT, 0,
org.bukkit.event.inventory.ClickType.LEFT, org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);
cancelledEvent.setCancelled(true); f.dispatcher.accept(cancelledEvent);
assertTrue(f.tasks.isEmpty());
var stale = new ManualCraftingControllerNativeTest.Actor(2, 2);
var staleEvent = new org.bukkit.event.inventory.CraftItemEvent(recipe, stale.fixture.view,
org.bukkit.event.inventory.InventoryType.SlotType.RESULT, 0,
org.bukkit.event.inventory.ClickType.LEFT, org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);
f.dispatcher.accept(staleEvent);
var staleCompletion = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(staleCompletion);
stale.fixture.grid.getMatrix()[0].setAmount(7); staleCompletion.run();
assertTrue(stale.fixture.view.getCursor().isEmpty());
storage(f).find(java.util.UUID.randomUUID()).get(5, TimeUnit.SECONDS); // Finish queued unissued cleanup before injecting another lock fault.
var failed = new ManualCraftingControllerNativeTest.Actor(2, 2);
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"),
java.nio.file.StandardOpenOption.WRITE); var lock = channel.lock()) {
assertTrue(lock.isValid());
var failedEvent = new org.bukkit.event.inventory.CraftItemEvent(recipe, failed.fixture.view,
org.bukkit.event.inventory.InventoryType.SlotType.RESULT, 0,
org.bukkit.event.inventory.ClickType.LEFT, org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);
f.dispatcher.accept(failedEvent);
var completion = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(completion); completion.run();
assertTrue(failed.fixture.view.getCursor().isEmpty());
}
assertTrue(rows("character.coins_initialized").isEmpty());
var actor = new ManualCraftingControllerNativeTest.Actor(2, 2);
var world = mock(org.bukkit.craftbukkit.CraftWorld.class);
when(world.getUID()).thenReturn(java.util.UUID.randomUUID()); when(world.getName()).thenReturn("world");
actor.eyeLocation = new org.bukkit.Location(world, 12, 70, 31);
var event = new org.bukkit.event.inventory.CraftItemEvent(recipe, actor.fixture.view,
org.bukkit.event.inventory.InventoryType.SlotType.RESULT, 0,
org.bukkit.event.inventory.ClickType.LEFT, org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);
f.dispatcher.accept(event); assertTrue(event.isCancelled());
var completion = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(completion); completion.run();
var id = new CharacterSheets().identify(actor.fixture.view.getCursor()).orElseThrow();
var saved = storage(f).find(id).get().orElseThrow();
assertEquals(BigInteger.valueOf(4), saved.coins()); assertTrue(saved.coinsInitialized());
var sheet = actor.fixture.view.getCursor().clone(); completion.run();
assertEquals(sheet, actor.fixture.view.getCursor()); assertEquals(1, actor.syncs);
ReskillEventsNativeTest.await(f, () -> rows("character.coins_initialized").size() == 1);
var values = rows("character.coins_initialized").getFirst().getAsJsonObject("data");
assertEquals("craft", values.get("source").getAsString()); assertEquals(id.toString(), values.get("character_id").getAsString());
assertEquals("4", values.get("balance").getAsString()); assertEquals(saved.revision().toString(), values.get("revision").getAsString());
assertEquals("4", values.get("configured_minimum").getAsString()); assertEquals("4", values.get("configured_maximum").getAsString());
f.plugin.setEnabled(false); f.awaitWorkers(); coinDrain(f);
}
assertEquals(1, rows("character.coins_initialized").size());
}
@Test void rejectedCoinReadsAndChangesReplayOriginalHistoryWithoutReapplyingOlderPolicy() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f);
var sender = new EquipmentAdminCommandNativeTest.Sender();
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "starting-coins-min"});
for (var value : List.of("2", "3")) {
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "starting-coins-min", value});
ReskillEventsNativeTest.await(f, () -> sender.messages.stream().anyMatch(message -> message.startsWith("Saved starting-coins-min=" + value)));
}
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "starting-coins-min"});
var feedback = List.copyOf(sender.messages);
var bytes = Files.readAllBytes(data.resolve("config.yml"));
f.plugin.setEnabled(false); f.awaitWorkers(); coinDrain(f);
var originals = new java.util.HashMap<String, Properties>();
try (var files = Files.list(data.resolve("coin-event-intents"))) {
for (var file : files.filter(path -> path.getFileName().toString().endsWith(".pending")).toList()) {
var properties = new Properties();
try (var input = Files.newInputStream(file)) { properties.load(input); }
originals.put(properties.getProperty("id"), properties);
}
}
assertEquals(4, originals.size()); assertTrue(rows("config.read").isEmpty()); assertTrue(rows("config.changed").isEmpty());
f.plugin.setEnabled(true); ready(f);
ReskillEventsNativeTest.await(f, () -> rows("config.read").size() == 2 && rows("config.changed").size() == 2);
var settings = (games.dmg.fantasystick.persistence.CoinSettings)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "coinSettings");
assertEquals("3", settings.value("starting-coins-min"));
assertArrayEquals(bytes, Files.readAllBytes(data.resolve("config.yml")), "Audit replay cannot rewrite policy");
assertEquals(feedback, sender.messages, "Audit replay cannot deliver command feedback again");
var all = new java.util.ArrayList<>(rows("config.read")); all.addAll(rows("config.changed"));
assertEquals(originals.keySet(), all.stream().map(row -> row.get("id").getAsString()).collect(java.util.stream.Collectors.toSet()));
for (var row : all) {
var original = originals.get(row.get("id").getAsString()); var values = row.getAsJsonObject("data");
assertEquals(original.getProperty("time"), row.get("time").getAsString());
assertEquals(original.getProperty("action"), values.get("action_id").getAsString());
assertEquals("starting-coins-min", values.get("key").getAsString());
assertEquals("future_character_initialization", values.get("application").getAsString());
assertEquals("console", values.get("actor_kind").getAsString());
}
assertEquals(java.util.Set.of("1", "3"), rows("config.read").stream()
.map(row -> row.getAsJsonObject("data").get("after").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertEquals(java.util.Set.of("1:2", "2:3"), rows("config.changed").stream().map(row -> {
var values = row.getAsJsonObject("data"); return values.get("before").getAsString() + ":" + values.get("after").getAsString();
}).collect(java.util.stream.Collectors.toSet()));
f.plugin.setEnabled(false); f.awaitWorkers(); coinDrain(f);
}
assertEquals(2, rows("config.read").size()); assertEquals(2, rows("config.changed").size());
}
private void seedCoins(FantasyStickLifecycleNativeTest.Actor actor, BigInteger balance, boolean initialized) throws Exception {
var old = actor.core.record;
var seeded = 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(), balance, initialized,
old.rewardReceipts(), old.pvpCoinEscrow(), old.gear(), old.sheetGeneration());
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(seeded, old.revision());
}
private static void coinDrain(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "coinAuditDrain"))
.toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
}
private static void ready(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog"))
.ready().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private List<JsonObject> rows(String type) {
try { return ReskillEventsNativeTest.records(data).stream()
.filter(row -> row.get("type").getAsString().endsWith(type)).toList(); }
catch (Exception failure) { throw new AssertionError("Cannot parse exported coin evidence", failure); }
}
}