feat(events): audit Quicker Regeneration II native journeys
Release / release (push) Failing after 10m29s

This commit is contained in:
dmg committed 2026-09-30 22:25:16 -04:00
1 parent 92d3afc2f0
commit 908b0b02aa
5 files changed
+297 -6

No files matched your search

@@ -896,7 +896,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
if (closed || passiveEngine == null || !unlock && !unlearn
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|| event.getTag() == null) return;
var player=connection.getPlayer();String raw=event.getTag().string();if(raw.length()>120)return;var match=ABILITY_PAYLOAD.matcher(raw);if(!match.matches())return;String passive=match.group(2);var definition=passives.find(passive).orElse(null);if(definition==null)return;var view=passiveViews.get(player.getUniqueId());boolean owned=games.dmg.fantasystick.passive.QuickerRegenerationI.ID.equals(passive);
var player=connection.getPlayer();String raw=event.getTag().string();if(raw.length()>120)return;var match=ABILITY_PAYLOAD.matcher(raw);if(!match.matches())return;String passive=match.group(2);var definition=passives.find(passive).orElse(null);if(definition==null)return;var view=passiveViews.get(player.getUniqueId());boolean owned=games.dmg.fantasystick.passive.QuickerRegenerationI.ID.equals(passive)
|| games.dmg.fantasystick.passive.QuickerRegenerationII.ID.equals(passive);
if (view == null || view.input().player() != player || !view.nonce().equals(match.group(1)) || !view.ids().contains(passive)
|| !passiveViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) {if(owned)refusePassive(player,definition,unlock,"stale_forged_or_repeated_action");return;}
var audit=owned&&passiveEvents!=null?passiveEvents.begin(view.input(),view.before(),definition,unlock):null;
@@ -29,7 +29,7 @@ public final class PassiveEventBridge {
public void configRead(String actorKind,UUID player,String playerName,String key,String value){config("config.read",actorKind,player,playerName,key,null,value);}
public void configChanged(String actorKind,UUID player,String playerName,String key,String before,String after){config("config.changed",actorKind,player,playerName,key,before,after);}
private void emit(String type,Action action,String outcome,String reason,CharacterRecord after){var before=action.before();var data=new LinkedHashMap<String,Object>();data.put("action_kind",action.learn()?"passive_learn":"passive_unlearn");data.put("passive_id",action.passive());data.put("expected_revision",before.revision().toString());data.put("before_revision",before.revision().toString());data.put("points_before",before.unspentPoints().toString());data.put("point_cost",action.definition().pointCost().toString());var requirements=new LinkedHashMap<String,Object>();action.definition().requirements().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry->requirements.put(entry.getKey().name(),entry.getValue().toString()));data.put("stat_prerequisites",requirements);data.put("passive_prerequisites",action.definition().prerequisites().stream().sorted().toList());data.put("outcome",outcome);if(after!=null){data.put("after_revision",after.revision().toString());data.put("points_after",after.unspentPoints().toString());int slot=(action.learn()?after.passives():before.passives()).indexOf(action.passive());data.put("passive_slot",slot);data.put("slot_index_base",0);data.put("native_publication","acknowledged");data.put("owned_cleanup",action.learn()?"passive_state_acknowledged":"passive_state_acknowledged");}if(reason!=null)data.put(type.startsWith("passive.")?"native_boundary":"reason",reason);try{sink.accept(new EventInput(id(action.actionId(),type),action.actionId(),clock.instant(),type,action.context(),Map.copyOf(data)));}catch(RuntimeException ignored){}
private void emit(String type,Action action,String outcome,String reason,CharacterRecord after){var before=action.before();var data=new LinkedHashMap<String,Object>();data.put("action_kind",action.learn()?"passive_learn":"passive_unlearn");data.put("passive_id",action.passive());data.put("expected_revision",before.revision().toString());data.put("before_revision",before.revision().toString());data.put("points_before",before.unspentPoints().toString());data.put("point_cost",action.definition().pointCost().toString());var requirements=new LinkedHashMap<String,Object>();action.definition().requirements().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry->requirements.put(entry.getKey().name(),entry.getValue().toString()));data.put("stat_prerequisites",requirements);var statValues=new LinkedHashMap<String,Object>();action.definition().requirements().keySet().stream().sorted().forEach(stat->{var value=before.stats().get(stat);if(value!=null)statValues.put(stat.name(),value.toString());});data.put("stat_values_before",statValues);data.put("passive_slots_before",before.passives());data.put("passive_prerequisites",action.definition().prerequisites().stream().sorted().toList());data.put("outcome",outcome);if(after!=null){data.put("after_revision",after.revision().toString());data.put("points_after",after.unspentPoints().toString());data.put("passive_slots_after",after.passives());int slot=(action.learn()?after.passives():before.passives()).indexOf(action.passive());data.put("passive_slot",slot);data.put("slot_index_base",0);data.put("native_publication","acknowledged");data.put("owned_cleanup",action.learn()?"passive_state_acknowledged":"passive_state_acknowledged");}if(reason!=null)data.put(type.startsWith("passive.")?"native_boundary":"reason",reason);try{sink.accept(new EventInput(id(action.actionId(),type),action.actionId(),clock.instant(),type,action.context(),Map.copyOf(data)));}catch(RuntimeException ignored){}
}
private void config(String type,String actorKind,UUID player,String playerName,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(playerName!=null)data.put("actor_player_name",playerName);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application","future_recovery_pulses");data.put("outcome","acknowledged");var context=player==null?null:new EventInput.Context(player,playerName,null,null,null,null);try{sink.accept(new EventInput(id(action,type),action,clock.instant(),type,context,Map.copyOf(data)));}catch(RuntimeException ignored){}
}
@@ -1282,9 +1282,15 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
}
private void quickerRegenerationIIConfiguration(CommandSender sender, String[] args) {
String key = games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY;
String before = quickerRegenerationIISettings.value();
String 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) {
sender.sendMessage(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY + "="
+ quickerRegenerationIISettings.value() + " (applies to future recovery pulses only)"); return;
if (passiveEvents != null) passiveEvents.configRead(actorKind, actorPlayer, actorName, key, before);
sender.sendMessage(key + "=" + before + " (applies to future recovery pulses only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.Update> operation;
try { operation = quickerRegenerationIISettings.set(args[2]); }
@@ -1297,7 +1303,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return; boolean applied = false;
if (failure == null) {
applied = quickerRegenerationIISettings.publish(saved);
if (applied) { quickerRegenerationII.configure(saved.settings()); passiveRegistry.replace(quickerRegenerationII.definition()); }
if (applied) {
quickerRegenerationII.configure(saved.settings()); passiveRegistry.replace(quickerRegenerationII.definition());
if (passiveEvents != null) passiveEvents.configChanged(actorKind, actorPlayer, actorName, key, before, saved.value());
}
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
@@ -0,0 +1,203 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import com.google.gson.JsonObject;
import games.dmg.fantasystick.events.LocalEventLog;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.OutOfCombatRecovery;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.EnumMap;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.TimeUnit;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityRegainHealthEvent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class QuickerRegenerationIIEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void cappedStackWithFeedbackOffReplaysExactRejectedIntentWithoutAnotherHeal() throws Exception {
configure();
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
eligible(a, new BigDecimal("23"), List.of("quicker_regeneration_i", "quicker_regeneration_ii", ""));
f.players.put(a.core.id, a.player);
assertTrue(f.playerCommand.getExecutor().onCommand(a.player, f.playerCommand, "fstick", new String[]{"feedback", "off"}));
f.tick(); f.activate(a);
int components = a.messageComponents.size();
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
recovery(f).sweep(); drain(f);
assertEquals(26, a.player.getHealth());
var saved = storage(f).find(a.core.record.id()).get().orElseThrow();
assertEquals(0, new BigDecimal("26").compareTo(saved.health()));
assertEquals(components, a.messageComponents.size());
f.plugin.setEnabled(false); f.awaitWorkers();
assertTrue(heals().isEmpty());
var pending = data.resolve("out-of-combat-recovery-event-intents");
Path intent;
try (var files = Files.list(pending)) {
var entries = files.filter(p -> p.getFileName().toString().endsWith(".pending")).toList();
assertEquals(1, entries.size()); intent = entries.getFirst();
}
var fields = new Properties();
try (var input = Files.newInputStream(intent)) { fields.load(input); }
f.plugin.setEnabled(true); ready(f);
for (int i = 0; i < 500 && heals().size() != 1; i++) Thread.sleep(10);
assertEquals(saved, storage(f).find(saved.id()).get().orElseThrow(), "Export replay must not save or heal again");
f.plugin.setEnabled(false);
var rows = heals(); assertEquals(1, rows.size());
assertEquals(fields.getProperty("id"), rows.getFirst().get("id").getAsString());
assertEquals(fields.getProperty("action"), rows.getFirst().getAsJsonObject("data").get("action_id").getAsString());
assertEquals(a.core.id.toString(), rows.getFirst().getAsJsonObject("data").get("player_id").getAsString());
var values = rows.getFirst().getAsJsonObject("data");
assertEquals("3.0", values.get("accepted_total").getAsString());
assertEquals("1", values.get("base_contribution").getAsString());
assertEquals("1", values.getAsJsonObject("passive_contributions").get("quicker_regeneration_i").getAsString());
assertEquals("1.0", values.getAsJsonObject("passive_contributions").get("quicker_regeneration_ii").getAsString());
assertEquals("acknowledged", values.get("native_publication").getAsString());
}
}
@Test void cancelledStackNeverChangesHealthRevisionOrClaimsRankHealing() throws Exception {
configure();
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
eligible(a, new BigDecimal("4"), List.of("quicker_regeneration_i", "quicker_regeneration_ii", ""));
f.players.put(a.core.id, a.player); f.activate(a);
var before = storage(f).find(a.core.record.id()).get().orElseThrow();
var calls = new java.util.ArrayList<Double>();
f.dispatcher.register(new Listener() {
@EventHandler public void veto(EntityRegainHealthEvent event) {
if (event.getEntity() == a.player && event.getRegainReason() == EntityRegainHealthEvent.RegainReason.CUSTOM) {
calls.add(event.getAmount()); event.setCancelled(true);
}
}
}, f.plugin);
recovery(f).sweep(); drain(f);
assertEquals(List.of(4.0), calls);
assertEquals(4, a.player.getHealth());
assertEquals(before, storage(f).find(before.id()).get().orElseThrow());
f.plugin.setEnabled(false);
}
assertTrue(heals().isEmpty());
assertTrue(records().stream().anyMatch(row -> row.get("type").getAsString().endsWith("action.resolved")
&& row.getAsJsonObject("data").has("reason")
&& row.getAsJsonObject("data").get("reason").getAsString().equals("native_event_cancelled")));
}
@Test void failedLearnThenFreshRetryReportsActualPrerequisitesAndAuditFailureCannotDuplicateRefund() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
eligible(a, new BigDecimal("4"), List.of("quicker_regeneration_i", "", ""));
f.players.put(a.core.id, a.player); f.activate(a); PartyBookNativeTest.open(f, a);
var before = storage(f).find(a.core.record.id()).get().orElseThrow();
var old = action(a, "[Learn]");
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()); PartyBookNativeTest.click(f, a, old); drain(f);
}
assertEquals(before, storage(f).find(before.id()).get().orElseThrow());
PartyBookNativeTest.click(f, a, old); drain(f);
assertEquals(before, storage(f).find(before.id()).get().orElseThrow(), "Old failed token is not a fresh retry");
PartyBookNativeTest.open(f, a); var fresh = action(a, "[Learn]");
PartyBookNativeTest.click(f, a, fresh); PartyBookNativeTest.click(f, a, fresh); drain(f);
var learned = storage(f).find(before.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(39), learned.unspentPoints());
assertEquals(List.of("quicker_regeneration_i", "quicker_regeneration_ii", ""), learned.passives());
var bridge = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "passiveEvents");
var sink = games.dmg.fantasystick.events.PassiveEventBridge.class.getDeclaredField("sink");
sink.setAccessible(true); var original = sink.get(bridge);
var remove = action(a, "[Unlearn]");
try {
sink.set(bridge, (java.util.function.Consumer<games.dmg.fantasystick.events.EventInput>)
event -> { throw new IllegalStateException("audit unavailable"); });
PartyBookNativeTest.click(f, a, remove); PartyBookNativeTest.click(f, a, remove); drain(f);
var refunded = storage(f).find(before.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
assertEquals(before.passives(), refunded.passives());
assertEquals(before.loadout(), refunded.loadout());
} finally { sink.set(bridge, original); }
f.plugin.setEnabled(false);
}
var rows = records().stream().filter(row -> row.getAsJsonObject("data").has("passive_id")
&& row.getAsJsonObject("data").get("passive_id").getAsString().equals("quicker_regeneration_ii")).toList();
var learned = rows.stream().filter(row -> row.get("type").getAsString().endsWith("passive.learned")).toList();
assertEquals(1, learned.size());
assertEquals(0, rows.stream().filter(row -> row.get("type").getAsString().endsWith("passive.unlearned")).count());
var values = learned.getFirst().getAsJsonObject("data");
assertTrue(values.has("stat_values_before"), "Evidence must include actual prerequisite state, not only its definition");
assertEquals("16", values.getAsJsonObject("stat_values_before").get("CON").getAsString());
assertEquals("14", values.getAsJsonObject("stat_values_before").get("WIS").getAsString());
assertEquals("quicker_regeneration_i", values.getAsJsonArray("passive_slots_before").get(0).getAsString());
assertEquals("", values.getAsJsonArray("passive_slots_before").get(1).getAsString());
assertEquals("quicker_regeneration_ii", values.getAsJsonArray("passive_slots_after").get(1).getAsString());
assertEquals("1", values.get("point_cost").getAsString());
assertTrue(rows.stream().anyMatch(row -> row.getAsJsonObject("data").has("reason")
&& row.getAsJsonObject("data").get("reason").getAsString().equals("save_or_native_publication_uncertain")));
assertTrue(rows.stream().anyMatch(row -> row.getAsJsonObject("data").has("reason")
&& row.getAsJsonObject("data").get("reason").getAsString().equals("stale_forged_or_repeated_action")));
}
private static net.minecraft.network.chat.ClickEvent.Custom action(
FantasyStickLifecycleNativeTest.Actor a, String label) {
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
for (var page : content.pages()) {
var found = action(page.raw(), label); if (found != null) return found;
}
throw new AssertionError("Missing rank-II " + label);
}
private static net.minecraft.network.chat.ClickEvent.Custom action(net.minecraft.network.chat.Component c, String label) {
if (c.getString().equals(label) && c.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom event
&& event.payload().orElse(null) instanceof net.minecraft.nbt.StringTag tag
&& tag.value().endsWith(":quicker_regeneration_ii")) return event;
for (var child : c.getSiblings()) { var found = action(child, label); if (found != null) return found; }
return null;
}
private void configure() throws Exception {
Files.writeString(data.resolve("config.yml"), "out-of-combat-healing-delay-seconds: 0\n"
+ "out-of-combat-healing-interval-seconds: 10\nout-of-combat-healing-amount: 1\n"
+ "quicker-regeneration-i-bonus-healing: 1\nquicker-regeneration-ii-bonus-healing: 2\n");
}
private void eligible(FantasyStickLifecycleNativeTest.Actor a, BigDecimal health, List<String> passives) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(a.core.record.id()).orElseThrow(); var stats = new EnumMap<>(old.stats());
stats.put(Stat.CON, BigInteger.valueOf(16)); stats.put(Stat.WIS, BigInteger.valueOf(14));
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
old.level(), old.experience(), BigInteger.valueOf(40), health, old.learned(), old.loadout(), passives,
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
private List<JsonObject> records() throws Exception { return ReskillEventsNativeTest.records(data); }
private List<JsonObject> heals() throws Exception {
return records().stream().filter(row -> row.get("type").getAsString().equals(LocalEventLog.TYPE_PREFIX + "combat.healed")
&& row.getAsJsonObject("data").has("recovery_kind")).toList();
}
private static void ready(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog"))
.ready().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
}
private static OutOfCombatRecovery recovery(FantasyStickLifecycleNativeTest.Fixture f) {
return (OutOfCombatRecovery) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecovery");
}
private static void drain(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
}
}
@@ -31,6 +31,13 @@ class QuickerRegenerationIINativeTest {
assertTrue(initial.contains("QuickerRegenerationIIQuickerRegenerationI,CON16,WIS14"), initial);
assertFalse(hasAction(a, "[Learn]", "quicker_regeneration_ii"));
var prerequisite = action(a, "[Learn]", "quicker_regeneration_i");
var payload = ((net.minecraft.nbt.StringTag) prerequisite.payload().orElseThrow()).value();
PartyBookNativeTest.click(f, a, new net.minecraft.network.chat.ClickEvent.Custom(prerequisite.id(),
java.util.Optional.of(net.minecraft.nbt.StringTag.valueOf(payload.replace(
"quicker_regeneration_i", "quicker_regeneration_ii"))))); drain(f);
assertEquals(BigInteger.valueOf(40), storage(f).find(a.core.record.id()).get().orElseThrow().unspentPoints());
PartyBookNativeTest.open(f, a);
PartyBookNativeTest.click(f, a, action(a, "[Learn]", "quicker_regeneration_i")); drain(f);
PartyBookNativeTest.click(f, a, action(a, "[Learn]", "quicker_regeneration_ii")); drain(f);
var learned = storage(f).find(a.core.record.id()).get().orElseThrow();
@@ -38,8 +45,12 @@ class QuickerRegenerationIINativeTest {
assertEquals(BigInteger.valueOf(38), learned.unspentPoints());
assertEquals(a.core.record.loadout(), learned.loadout());
assertTrue(f.playerCommand.getExecutor().onCommand(a.player, f.playerCommand, "fstick", new String[]{"feedback", "off"}));
f.tick();
int presentationBefore = a.messageComponents.size();
var recovery = (OutOfCombatRecovery) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecovery");
recovery.sweep(); PartyBookNativeTest.drain(f);
assertEquals(presentationBefore, a.messageComponents.size(), "Feedback opt-out cannot suppress the single audited stacked pulse");
assertEquals(8, a.player.getHealth(), "The single pulse is base 1 + Regeneration I 1 + Regeneration II 2");
learned = storage(f).find(learned.id()).get().orElseThrow(); assertEquals(0, new BigDecimal("8").compareTo(learned.health()));
@@ -68,6 +79,35 @@ class QuickerRegenerationIINativeTest {
assertEquals(List.of("", "", ""), refunded.passives()); assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
assertEquals(refunded.passives(), storage(f).find(refunded.id()).get().orElseThrow().passives());
f.plugin.setEnabled(false);
var rows = records(data);
var rankII = rows.stream().filter(row -> row.getAsJsonObject("data").has("passive_id")
&& row.getAsJsonObject("data").get("passive_id").getAsString().equals("quicker_regeneration_ii")).toList();
var successes = rankII.stream().filter(row -> java.util.Set.of(
games.dmg.fantasystick.events.LocalEventLog.TYPE_PREFIX + "passive.learned",
games.dmg.fantasystick.events.LocalEventLog.TYPE_PREFIX + "passive.unlearned")
.contains(row.get("type").getAsString())).toList();
assertEquals(2, successes.size(), "Actual rank-II book learn/refund must reach JSONL once each");
var learn = successes.stream().filter(row -> row.get("type").getAsString().endsWith(".learned")).findFirst().orElseThrow();
var unlearn = successes.stream().filter(row -> row.get("type").getAsString().endsWith(".unlearned")).findFirst().orElseThrow();
assertEquals(a.core.id.toString(), learn.getAsJsonObject("data").get("player_id").getAsString());
assertEquals(c.core.id.toString(), unlearn.getAsJsonObject("data").get("player_id").getAsString());
assertEquals(refunded.id().toString(), learn.getAsJsonObject("data").get("character_id").getAsString());
assertEquals("39", learn.getAsJsonObject("data").get("points_before").getAsString());
assertEquals("38", learn.getAsJsonObject("data").get("points_after").getAsString());
assertEquals("38", unlearn.getAsJsonObject("data").get("points_before").getAsString());
assertEquals("39", unlearn.getAsJsonObject("data").get("points_after").getAsString());
assertEquals(1, learn.getAsJsonObject("data").get("passive_slot").getAsInt());
assertTrue(rankII.stream().anyMatch(row -> row.get("type").getAsString().endsWith("action.resolved")
&& row.getAsJsonObject("data").get("reason").getAsString().equals("requirements")));
var heals = rows.stream().filter(row -> row.get("type").getAsString().equals(games.dmg.fantasystick.events.LocalEventLog.TYPE_PREFIX + "combat.healed")
&& row.getAsJsonObject("data").has("recovery_kind")).toList();
assertEquals(1, heals.size(), "The stacked pulse is one heal, not one heal per rank");
var heal = heals.getFirst().getAsJsonObject("data");
assertEquals("4.0", heal.get("accepted_total").getAsString());
assertEquals("1", heal.get("base_contribution").getAsString());
assertEquals("1", heal.getAsJsonObject("passive_contributions").get("quicker_regeneration_i").getAsString());
assertEquals("2.0", heal.getAsJsonObject("passive_contributions").get("quicker_regeneration_ii").getAsString());
}
}
@@ -84,12 +124,26 @@ class QuickerRegenerationIINativeTest {
var recovery = (OutOfCombatRecovery) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecovery");
recovery.sweep(); PartyBookNativeTest.drain(f);
assertEquals(5, a.player.getHealth(), "An orphaned second rank cannot execute without Regeneration I");
// Native acknowledgement precedes the independent asynchronous outbox export.
for (int i = 0; i < 500 && records(data).stream().noneMatch(row -> row.get("type").getAsString()
.equals(games.dmg.fantasystick.events.LocalEventLog.TYPE_PREFIX + "combat.healed")); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
var heals = records(data).stream().filter(row -> row.get("type").getAsString()
.equals(games.dmg.fantasystick.events.LocalEventLog.TYPE_PREFIX + "combat.healed")
&& row.getAsJsonObject("data").has("recovery_kind")).toList();
assertEquals(1, heals.size());
assertEquals("1.0", heals.getFirst().getAsJsonObject("data").get("accepted_total").getAsString());
assertEquals("0", heals.getFirst().getAsJsonObject("data").get("configured_quicker_regeneration_ii").getAsString());
assertFalse(heals.getFirst().getAsJsonObject("data").has("passive_contributions"));
}
}
@Test void configurationIsPermissionAwareDurableAndPrefixFiltered() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var sender = new EquipmentAdminCommandNativeTest.Sender();
f.plugin.setEnabled(true);
((games.dmg.fantasystick.events.LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog"))
.ready().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("quicker-regeneration-ii-bonus-healing"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-ii"}));
assertEquals(List.of("2"), f.command.tabComplete(sender.port, "fantasystick",
@@ -105,9 +159,33 @@ class QuickerRegenerationIINativeTest {
var settings = (games.dmg.fantasystick.persistence.QuickerRegenerationIISettings)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "quickerRegenerationIISettings");
assertEquals("5", settings.value());
for (var invalid : List.of("-1", "1025", "1.5", "oops")) {
f.command.execute(sender.port, "fstickadmin", new String[]{"config", "quicker-regeneration-ii-bonus-healing", invalid});
assertTrue(sender.messages.getLast().contains("invalid"));
}
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE); var lock = channel.lock()) {
assertTrue(lock.isValid());
f.command.execute(sender.port, "fstickadmin", new String[]{"config", "quicker-regeneration-ii-bonus-healing", "6"});
var failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(failed); failed.run();
}
assertEquals("5", settings.value()); assertTrue(sender.messages.getLast().contains("save failed"));
f.plugin.setEnabled(false);
var events = records(data).stream().filter(row -> row.getAsJsonObject("data").has("key")
&& row.getAsJsonObject("data").get("key").getAsString().equals("quicker-regeneration-ii-bonus-healing")).toList();
assertEquals(2, events.size(), "Only the authorized read and acknowledged durable change are successes");
var read = events.stream().filter(row -> row.get("type").getAsString().endsWith("config.read")).findFirst().orElseThrow();
var change = events.stream().filter(row -> row.get("type").getAsString().endsWith("config.changed")).findFirst().orElseThrow();
assertEquals("2", read.getAsJsonObject("data").get("after").getAsString());
assertEquals("2", change.getAsJsonObject("data").get("before").getAsString());
assertEquals("5", change.getAsJsonObject("data").get("after").getAsString());
}
}
private static List<com.google.gson.JsonObject> records(Path data) throws Exception {
return ReskillEventsNativeTest.records(data);
}
private static void makeEligible(Path data, FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health) throws Exception {
makeEligible(data, actor, health, List.of("", "", ""));
}