feat(events): retain acknowledged coin owner audit intents
Release / release (push) Failing after 11m8s
Release / release (push) Failing after 11m8s
This commit is contained in:
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); }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user