feat(events): record permanent stat mutations
Release / release (push) Successful in 14m32s

This commit is contained in:
dmg committed 2026-09-24 22:00:02 -04:00
1 parent 0bdb43b2bb
commit 60ccb34ab6
4 files changed
+121 -25

No files matched your search

@@ -167,6 +167,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
private games.dmg.fantasystick.events.RareEventBridge rareEvents;
private games.dmg.fantasystick.events.BookEventBridge bookEvents;
private games.dmg.fantasystick.events.ProgressionEventBridge progressionEvents;
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
@Override public void onEnable() {
@@ -705,13 +706,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
downedEvents = new games.dmg.fantasystick.events.DownedEventBridge(eventLog::emit, Clock.systemUTC());
rareEvents = new games.dmg.fantasystick.events.RareEventBridge(eventLog::emit, Clock.systemUTC());rareShop.attachEvents(rareEvents);
bookEvents = new games.dmg.fantasystick.events.BookEventBridge(eventLog::emit, Clock.systemUTC());books.attachEvents(bookEvents);
progressionEvents = new games.dmg.fantasystick.events.ProgressionEventBridge(eventLog::emit, Clock.systemUTC());books.attachProgressionEvents(progressionEvents);
sessions.attachDownedEvents(downedEvents);
downedController.attachEvents(downedEvents);
revive.attachDownedEvents(downedEvents);
admin.attachDownedEvents(downedEvents);
getServer().getPluginManager().registerEvents(eventCapture, this);
} catch (java.io.IOException failure) {
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null;
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null;
getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure);
}
getServer().getPluginManager().registerEvents(crafting, this);
@@ -814,7 +816,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
combatFeedback = null;
var closingEvents = sessionEvents; sessionEvents = null;
var closingEventLog = eventLog; eventLog = null;
var closingEventCapture = eventCapture; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null;
var closingEventCapture = eventCapture; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null;
var closingBooks = books; books = null;
var closingPartyEvents = partyEvents; partyEvents = null;
var closingPartyCombat = partyCombat; partyCombat = null;
@@ -38,6 +38,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
private final Map<UUID, games.dmg.fantasystick.events.BookEventBridge.Request> pendingBookEvents = new ConcurrentHashMap<>();
private games.dmg.fantasystick.events.BookEventBridge bookEvents;
private games.dmg.fantasystick.events.ProgressionEventBridge progressionEvents;
private final Map<UUID, Object> adminPending = new ConcurrentHashMap<>();
private final Map<UUID, AdminView> adminViews = new ConcurrentHashMap<>();
private final Map<UUID, SavedView> savedViews = new ConcurrentHashMap<>();
@@ -63,7 +64,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
games.dmg.fantasystick.session.EquipmentSessions.Active active,
int previous, int next) { }
private record SavedView(String nonce, UUID administrator, UUID character, int previous, int next) { }
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision) { }
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision, CharacterRecord before) { }
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
private record PassiveView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
private record RareView(String nonce,ControlInput input,java.util.Map<String,java.math.BigInteger> quantities){}
@@ -120,6 +121,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
public void attachRareInventory(games.dmg.fantasystick.shop.RareInventoryDirectory inventory,games.dmg.fantasystick.shop.RarePotionService potions,games.dmg.fantasystick.shop.RareReskillService reskills,games.dmg.fantasystick.shop.RareMagicCoreService magicCores){if(closed||rareInventory!=null)throw new IllegalStateException("Rare inventory already attached or books closed");rareInventory=Objects.requireNonNull(inventory);rarePotions=Objects.requireNonNull(potions);rareReskills=Objects.requireNonNull(reskills);rareMagicCores=Objects.requireNonNull(magicCores);}
public void attachEvents(games.dmg.fantasystick.events.BookEventBridge events){if(closed||bookEvents!=null)throw new IllegalStateException("Book events already attached or controller closed");bookEvents=Objects.requireNonNull(events);}
public void attachProgressionEvents(games.dmg.fantasystick.events.ProgressionEventBridge events){if(closed||progressionEvents!=null)throw new IllegalStateException("Progression events already attached or controller closed");progressionEvents=Objects.requireNonNull(events);}
@Override public void accept(ControlInput input) { load(input, 0, true); }
@@ -312,7 +314,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
meta.setTitle("Character");
meta.setAuthor("Fantasy Stick");
var statView = interactive ? new StatView(UUID.randomUUID().toString(), input, record.revision()) : null;
var statView = interactive ? new StatView(UUID.randomUUID().toString(), input, record.revision(), record) : null;
var allOverview = overviewPages(record, subject, statView, savedState);
int start = Math.min(offset, (allOverview.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
int end = Math.min(start + VOLUME_SIZE, allOverview.size());
@@ -789,49 +791,53 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
public void statAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
if (closed || !STATS.equals(event.getIdentifier())
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
var player = connection.getPlayer();
var player = connection.getPlayer();if(event.getTag()==null)return;String raw=event.getTag().string();if(raw.length()>80)return;var match=STAT_PAYLOAD.matcher(raw);if(!match.matches())return;var stat=Stat.valueOf(match.group(2));
var view = statViews.get(player.getUniqueId());
if (view == null || view.input().player() != player || event.getTag() == null) return;
String raw = event.getTag().string();
if (raw.length() > 80) return;
var match = STAT_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1))
|| !statViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
var stat = Stat.valueOf(match.group(2));
if (view == null || view.input().player() != player || !view.nonce().equals(match.group(1))
|| !statViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) { refuseStat(player,stat,"stale_forged_or_repeated_action"); return; }
var currentHealth = java.math.BigDecimal.valueOf(player.getHealth());
var nativeLimit = NativeHealthLimit.read();
characters.purchaseStat(view.input().session().character(), view.revision(), stat, currentHealth, nativeLimit)
.whenComplete((purchase, failure) -> {
try {
nextTick.accept(() -> finishPurchase(view, stat, purchase, failure));
} catch (RuntimeException rejected) { report(view.input(), rejected); }
});
var nativeMaximum=java.math.BigDecimal.valueOf(java.util.Objects.requireNonNull(player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue());
var audit=progressionEvents==null?null:progressionEvents.begin(view.input(),view.before(),stat,currentHealth,nativeMaximum,nativeLimit);
try {
characters.purchaseStat(view.input().session().character(), view.revision(), stat, currentHealth, nativeLimit)
.whenComplete((purchase, failure) -> {
try { nextTick.accept(() -> finishPurchase(view, stat, audit, purchase, failure)); }
catch (RuntimeException rejected) { if(audit!=null){if(purchase!=null&&purchase.status()==CharacterStorage.StatPurchaseStatus.APPLIED)progressionEvents.increased(audit,purchase.record().orElseThrow(),"scheduler_rejected_before_native_publication",purchase.record().orElseThrow().health());else progressionEvents.failed(audit,"scheduler_rejected");}report(view.input(), rejected); }
});
} catch (RuntimeException failure) { if(audit!=null)progressionEvents.failed(audit,"purchase_rejected");report(view.input(),failure); }
}
private void finishPurchase(StatView view, Stat stat, CharacterStorage.StatPurchase purchase, Throwable failure) {
private void refuseStat(org.bukkit.entity.Player player,Stat stat,String reason){if(progressionEvents==null)return;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var current=sessions.currentRecord(active).orElse(null);if(current!=null)progressionEvents.refused(player,active,current,stat,NativeHealthLimit.read(),reason);}
private void finishPurchase(StatView view, Stat stat, games.dmg.fantasystick.events.ProgressionEventBridge.Action audit, CharacterStorage.StatPurchase purchase, Throwable failure) {
var input = view.input();
if (closed || !input.stillCurrent()) return;
if (failure != null) {
report(input, failure);
input.player().sendMessage("Stat purchase could not be saved; no character state was changed.");
if(audit!=null)progressionEvents.failed(audit,"save_failed");report(input, failure);
if(!closed&&input.player().isOnline())input.player().sendMessage("Stat purchase could not be saved; no character state was changed.");
return;
}
if (purchase.status() == CharacterStorage.StatPurchaseStatus.APPLIED) {
var saved = purchase.record().orElseThrow();
if(closed||!input.stillCurrent()){if(audit!=null)progressionEvents.increased(audit,saved,closed?"plugin_shutdown_before_native_publication":"session_changed_before_native_publication",saved.health());return;}
try {
if (!sessions.acknowledgeProgress(input.session(), view.revision(), saved)) {
if(audit!=null)progressionEvents.increased(audit,saved,"session_changed_before_native_publication",saved.health());
input.player().sendMessage("Stat purchase was saved; reopen the character after the session transition.");
return;
}
} catch (RuntimeException nativeFailure) {
report(input, nativeFailure);
if(audit!=null)progressionEvents.increased(audit,saved,"native_publication_failed",saved.health());report(input, nativeFailure);
input.player().sendMessage("Stat purchase was saved, but native health refresh failed; exit for recovery.");
return;
}
if(audit!=null)progressionEvents.increased(audit,saved,"acknowledged",java.math.BigDecimal.valueOf(input.player().getHealth()));
input.player().sendMessage(stat + " increased to " + saved.stats().get(stat) + ".");
load(input, 0, false);
return;
}
if(audit!=null)progressionEvents.refused(audit,purchase.status().name().toLowerCase(java.util.Locale.ROOT),purchase.record().orElse(null));
if(closed||!input.stillCurrent())return;
String message = switch (purchase.status()) {
case STALE -> "That character page is stale; reopen it before spending points.";
case NO_POINTS -> "This character has no unspent points.";
@@ -0,0 +1,77 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
/** Feature-owned permanent-stat outcomes, isolated from purchase and native-health authority. */
public final class ProgressionEventBridge {
public record Action(UUID actionId, EventInput.Context context, Stat stat, BigInteger expectedRevision,
BigInteger statBefore, BigInteger pointsBefore, BigDecimal healthBefore,
BigDecimal maximumBefore, BigDecimal nativeLimit) {
public Action {
Objects.requireNonNull(actionId); Objects.requireNonNull(context); Objects.requireNonNull(stat);
Objects.requireNonNull(expectedRevision); Objects.requireNonNull(statBefore); Objects.requireNonNull(pointsBefore);
Objects.requireNonNull(healthBefore); Objects.requireNonNull(maximumBefore); Objects.requireNonNull(nativeLimit);
}
}
private final Consumer<EventInput> sink;
private final Clock clock;
public ProgressionEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);
}
public Action begin(ControlInput input, CharacterRecord before, Stat stat, BigDecimal health,
BigDecimal maximum, BigDecimal nativeLimit) {
Objects.requireNonNull(input);Objects.requireNonNull(before);Objects.requireNonNull(stat);
var action=new Action(UUID.randomUUID(),context(input,before.name()),stat,before.revision(),
before.stats().get(stat),before.unspentPoints(),health,maximum.min(nativeLimit),nativeLimit);
emit("action.observed",action,"observed",null,null,null,null);
return action;
}
public void refused(Action action,String reason,CharacterRecord current){
emit("action.resolved",action,"refused",reason,current,null,null);
}
public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null,null,null);}
public void increased(Action action,CharacterRecord saved,String nativePublication,BigDecimal nativeHealthAfter){
emit("action.resolved",action,"accepted","acknowledged",saved,nativePublication,nativeHealthAfter);
emit("character.stat_increased",action,"accepted","acknowledged",saved,nativePublication,nativeHealthAfter);
}
public void refused(org.bukkit.entity.Player player,games.dmg.fantasystick.session.EquipmentSessions.Active active,
CharacterRecord current,Stat stat,BigDecimal nativeLimit,String reason){
var input=new ControlInput(player,active,0,"",ControlInput.Kind.BOOK,ControlInput.Gesture.USE,null,0,0,()->true);
var action=begin(input,current,stat,BigDecimal.valueOf(player.getHealth()),current.maximumHealth().min(nativeLimit),nativeLimit);
emit("action.resolved",action,"refused",reason,current,null,null);
}
private void emit(String type,Action action,String outcome,String reason,CharacterRecord after,
String nativePublication,BigDecimal nativeHealthAfter){
var data=new LinkedHashMap<String,Object>();data.put("action_kind","character_stat_increase");
data.put("stat_id",action.stat().name());data.put("expected_revision",action.expectedRevision().toString());
data.put("before_revision",action.expectedRevision().toString());data.put("stat_before",action.statBefore().toString());
data.put("points_before",action.pointsBefore().toString());data.put("health_before",action.healthBefore().toPlainString());
data.put("maximum_health_before",action.maximumBefore().toPlainString());data.put("native_health_ceiling",action.nativeLimit().toPlainString());
data.put("outcome",outcome);if(reason!=null)data.put(type.equals("character.stat_increased")?"native_boundary":"reason",reason);
if(after!=null){data.put("after_revision",after.revision().toString());data.put("stat_after",after.stats().get(action.stat()).toString());data.put("points_after",after.unspentPoints().toString());data.put("health_after",(nativeHealthAfter==null?after.health():nativeHealthAfter).toPlainString());data.put("maximum_health_after",after.maximumHealth().min(action.nativeLimit()).toPlainString());}
if(nativePublication!=null)data.put("native_publication",nativePublication);
try{sink.accept(new EventInput(id(action.actionId(),type),action.actionId(),clock.instant(),type,action.context(),Map.copyOf(data)));}catch(RuntimeException ignored){}
}
private EventInput.Context context(ControlInput input,String characterName){var player=input.player();return new EventInput.Context(player.getUniqueId(),player.getName(),input.session().character(),characterName,input.session().session(),location(player.getLocation()));}
private EventInput.Location location(Location location){return new EventInput.Location(location.getWorld().getUID(),location.getWorld().getName(),location.getX(),location.getY(),location.getZ(),clock.instant(),"exact");}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:progression:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
}
@@ -22,7 +22,7 @@ class CharacterBookNativeTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookEventSinkFailureCannotChangeTheAlreadyAuthorizedNativeBookPath() {
var player=org.mockito.Mockito.mock(org.bukkit.entity.Player.class);var world=org.mockito.Mockito.mock(org.bukkit.World.class);var record=org.mockito.Mockito.mock(games.dmg.fantasystick.persistence.CharacterRecord.class);var playerId=UUID.randomUUID();var active=new games.dmg.fantasystick.session.EquipmentSessions.Active(playerId,UUID.randomUUID(),UUID.randomUUID());org.mockito.Mockito.when(player.getUniqueId()).thenReturn(playerId);org.mockito.Mockito.when(player.getName()).thenReturn("Player");org.mockito.Mockito.when(world.getUID()).thenReturn(UUID.randomUUID());org.mockito.Mockito.when(world.getName()).thenReturn("world");org.mockito.Mockito.when(player.getLocation()).thenReturn(new org.bukkit.Location(world,1,2,3));org.mockito.Mockito.when(record.name()).thenReturn("Hero");org.mockito.Mockito.when(record.revision()).thenReturn(java.math.BigInteger.ONE);var input=new games.dmg.fantasystick.session.ControlInput(player,active,0,"",games.dmg.fantasystick.session.ControlInput.Kind.BOOK,games.dmg.fantasystick.session.ControlInput.Gesture.USE,null,0,0,()->true);var bridge=new games.dmg.fantasystick.events.BookEventBridge(event->{throw new IllegalStateException("disk");},java.time.Clock.systemUTC());assertDoesNotThrow(()->{var request=bridge.request(input,record,0);bridge.opened(request,record,3,1);bridge.refused(request,"retry");});
var player=org.mockito.Mockito.mock(org.bukkit.entity.Player.class);var world=org.mockito.Mockito.mock(org.bukkit.World.class);var record=org.mockito.Mockito.mock(games.dmg.fantasystick.persistence.CharacterRecord.class);var playerId=UUID.randomUUID();var active=new games.dmg.fantasystick.session.EquipmentSessions.Active(playerId,UUID.randomUUID(),UUID.randomUUID());org.mockito.Mockito.when(player.getUniqueId()).thenReturn(playerId);org.mockito.Mockito.when(player.getName()).thenReturn("Player");org.mockito.Mockito.when(world.getUID()).thenReturn(UUID.randomUUID());org.mockito.Mockito.when(world.getName()).thenReturn("world");org.mockito.Mockito.when(player.getLocation()).thenReturn(new org.bukkit.Location(world,1,2,3));org.mockito.Mockito.when(record.name()).thenReturn("Hero");org.mockito.Mockito.when(record.revision()).thenReturn(java.math.BigInteger.ONE);org.mockito.Mockito.when(record.stats()).thenReturn(java.util.Map.of(games.dmg.fantasystick.progression.Stat.CON,java.math.BigInteger.ZERO));org.mockito.Mockito.when(record.unspentPoints()).thenReturn(java.math.BigInteger.ONE);org.mockito.Mockito.when(record.maximumHealth()).thenReturn(java.math.BigDecimal.TEN);org.mockito.Mockito.when(record.health()).thenReturn(java.math.BigDecimal.ONE);var input=new games.dmg.fantasystick.session.ControlInput(player,active,0,"",games.dmg.fantasystick.session.ControlInput.Kind.BOOK,games.dmg.fantasystick.session.ControlInput.Gesture.USE,null,0,0,()->true);var bridge=new games.dmg.fantasystick.events.BookEventBridge(event->{throw new IllegalStateException("disk");},java.time.Clock.systemUTC());var progression=new games.dmg.fantasystick.events.ProgressionEventBridge(event->{throw new IllegalStateException("disk");},java.time.Clock.systemUTC());assertDoesNotThrow(()->{var request=bridge.request(input,record,0);bridge.opened(request,record,3,1);bridge.refused(request,"retry");var action=progression.begin(input,record,games.dmg.fantasystick.progression.Stat.CON,java.math.BigDecimal.ONE,java.math.BigDecimal.TEN,java.math.BigDecimal.valueOf(1024));progression.increased(action,record,"acknowledged",java.math.BigDecimal.ONE);});
}
@Test void registeredOpenReachesOneAcknowledgedFeatureEventAfterAuthoritativeReadAndNativeOpen() throws Exception {
@@ -112,7 +112,17 @@ class CharacterBookNativeTest {
assertEquals(saved, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow(),
"A rendered purchase action is single-use");
}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.opened")).count(),"The automatic post-purchase refresh must not duplicate a user-requested book-open outcome");
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.opened")).count(),"The automatic post-purchase refresh must not duplicate a user-requested book-open outcome");var increased=events.stream().filter(event->event.get("type").getAsString().endsWith("character.stat_increased")).toList();assertEquals(1,increased.size());var value=increased.getFirst().getAsJsonObject("data");assertEquals("CON",value.get("stat_id").getAsString());assertEquals("0",value.get("stat_before").getAsString());assertEquals("1",value.get("stat_after").getAsString());assertEquals("40",value.get("points_before").getAsString());assertEquals("39",value.get("points_after").getAsString());assertEquals("5.0",value.get("health_before").getAsString());assertEquals("6.0",value.get("health_after").getAsString());assertEquals("10.0",value.get("maximum_health_before").getAsString());assertEquals("11",value.get("maximum_health_after").getAsString());assertEquals("acknowledged",value.get("native_publication").getAsString());var action=value.get("action_id").getAsString();assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("action.observed")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("character_stat_increase")&&event.getAsJsonObject("data").get("action_id").getAsString().equals(action)).count());assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("character_stat_increase")&&event.getAsJsonObject("data").get("action_id").getAsString().equals(action)).count());
}
@Test void everyPermanentStatPublishesExactlyOneAcknowledgedMutation() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);f.activate(a);PartyBookNativeTest.open(f,a);for(var stat:games.dmg.fantasystick.progression.Stat.values()){PartyBookNativeTest.click(f,a,PartyBookNativeTest.button(a,"[+1 "+stat+"]"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}f.plugin.setEnabled(false);f.awaitWorkers();}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var increased=events.stream().filter(event->event.get("type").getAsString().endsWith("character.stat_increased")).toList();assertEquals(6,increased.size());assertEquals(java.util.Arrays.stream(games.dmg.fantasystick.progression.Stat.values()).map(Enum::name).collect(java.util.stream.Collectors.toSet()),increased.stream().map(event->event.getAsJsonObject("data").get("stat_id").getAsString()).collect(java.util.stream.Collectors.toSet()));assertEquals(6,increased.stream().map(event->event.get("id").getAsString()).distinct().count());assertTrue(increased.stream().allMatch(event->event.getAsJsonObject("data").get("native_publication").getAsString().equals("acknowledged")));
}
@Test void conPurchaseAtTheNativeCeilingRecordsStatAndPointChangeWithoutInventingHealing() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var old=a.core.record;var stats=new java.util.EnumMap<games.dmg.fantasystick.progression.Stat,java.math.BigInteger>(games.dmg.fantasystick.progression.Stat.class);stats.putAll(old.stats());stats.put(games.dmg.fantasystick.progression.Stat.CON,java.math.BigInteger.valueOf(1014));var prepared=new games.dmg.fantasystick.persistence.CharacterRecord(old.id(),old.name(),old.revision().add(java.math.BigInteger.ONE),stats,old.level(),old.experience(),java.math.BigInteger.ONE,java.math.BigDecimal.TEN,old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,old.deathId(),old.diedAt(),old.coins(),old.coinsInitialized(),old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(prepared,old.revision());f.players.put(a.core.id,a.player);f.activate(a);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,PartyBookNativeTest.button(a,"[+1 CON]"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);f.plugin.setEnabled(false);f.awaitWorkers();}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var value=events.stream().filter(event->event.get("type").getAsString().endsWith("character.stat_increased")).map(event->event.getAsJsonObject("data")).findFirst().orElseThrow();assertEquals("1014",value.get("stat_before").getAsString());assertEquals("1015",value.get("stat_after").getAsString());assertEquals(0,new java.math.BigDecimal(value.get("maximum_health_before").getAsString()).compareTo(new java.math.BigDecimal(value.get("maximum_health_after").getAsString())));assertEquals(0,new java.math.BigDecimal(value.get("health_before").getAsString()).compareTo(new java.math.BigDecimal(value.get("health_after").getAsString())));
}
@Test void conPurchaseKeepsItsOwnMaximumAndOneTimeGainDespiteExternalModifiers() throws Exception {
@@ -185,6 +195,7 @@ class CharacterBookNativeTest {
assertTrue(PartyBookNativeTest.text(b).contains("STR: 1"));
assertEquals(saved.stats(), characters(f).find(saved.id()).get(5, TimeUnit.SECONDS).orElseThrow().stats());
}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var statEvents=events.stream().filter(event->event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("character_stat_increase")).toList();assertEquals(1,statEvents.stream().filter(event->event.get("type").getAsString().endsWith("character.stat_increased")&&event.getAsJsonObject("data").get("stat_id").getAsString().equals("STR")).count());assertTrue(statEvents.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")).map(event->event.getAsJsonObject("data")).anyMatch(value->value.get("outcome").getAsString().equals("failed")&&value.get("reason").getAsString().equals("save_failed")));assertTrue(statEvents.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")).map(event->event.getAsJsonObject("data")).anyMatch(value->value.get("outcome").getAsString().equals("refused")&&value.get("reason").getAsString().equals("stale_forged_or_repeated_action")));
}
@Test void nativePagesKeepLargeNumbersUnicodeNamesAndUnknownAbilitiesReadableAndComplete() throws Exception {