Compare commits

...
10 Commits
Author SHA1 Message Date
dmg 8f2501f1c0 feat(pack): export native delivery and client statuses
Release / release (push) Failing after 18m13s
2026-09-26 14:48:15 -04:00
dmg 2f7a72355d feat(party): export acknowledged capacity changes
Release / release (push) Successful in 17m51s
2026-09-26 14:18:28 -04:00
dmg 01fd2f10ff feat(roleplay): export truthful availability changes
Release / release (push) Failing after 10m2s
2026-09-26 13:59:29 -04:00
dmg 6ac4867821 feat(config): export acknowledged switch-delay changes
Release / release (push) Successful in 18m8s
2026-09-26 13:38:06 -04:00
dmg b0996b0c6e feat(revival): export acknowledged admin Tail events
Release / release (push) Successful in 17m45s
2026-09-26 13:19:05 -04:00
dmg 850a04c4a0 test(combat): await acknowledged native session exit
Release / release (push) Successful in 10m29s
2026-09-26 13:00:55 -04:00
dmg 439c1b9cdd feat(issuance): export acknowledged admin sheet events
Release / release (push) Successful in 18m0s
2026-09-26 12:25:24 -04:00
dmg 2ae8f1ddf9 test(fishing): await acknowledged catch feedback
Release / release (push) Successful in 18m0s
2026-09-26 10:38:33 -04:00
dmg 912c077da1 fix(recovery): preserve pulses when audit world unloads 2026-09-26 10:38:33 -04:00
dmg 9b378b4237 feat(inspire): export accepted native absorption events
Release / release (push) Failing after 9m42s
2026-09-26 08:57:01 -04:00
33 changed files with 3675 additions and 70 deletions

No files matched your search

@@ -174,6 +174,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.events.PassiveEventBridge passiveEvents;
private games.dmg.fantasystick.events.RecoveryEventBridge recoveryEvents;
private games.dmg.fantasystick.events.ReviveEventBridge reviveEvents;
private games.dmg.fantasystick.events.AdminRevivalEventBridge adminRevivalEvents;
private java.util.concurrent.CompletionStage<Void> adminRevivalAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.PartyEventBridge partyAuditEvents;
private games.dmg.fantasystick.events.CombatRewardEventBridge combatRewardEvents;
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
@@ -182,6 +184,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
private java.util.concurrent.CompletionStage<Void> nutritionAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.SwitchDelayEventBridge switchAuditEvents;
private java.util.concurrent.CompletionStage<Void> switchAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.SwitchConfigEventBridge switchConfigEvents;
private java.util.concurrent.CompletionStage<Void> switchConfigAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.RoleplayAvailabilityEventBridge roleplayAvailabilityEvents;
private java.util.concurrent.CompletionStage<Void> roleplayAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.PartyCapacityEventBridge partyCapacityEvents;
private java.util.concurrent.CompletionStage<Void> partyCapacityAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.SheetResourcePackEventBridge sheetResourcePackEvents;
private java.util.concurrent.CompletionStage<Void> sheetResourcePackAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityAuditEvents;
private java.util.concurrent.CompletionStage<Void> identityAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.EquipmentEventBridge equipmentAuditEvents;
@@ -767,6 +777,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityEngine.enableCastEventsFor("stone_skin");
abilityEngine.enableCastEventsFor("shield_bash");
abilityEngine.enableCastEventsFor("hex");
abilityEngine.enableCastEventsFor("inspire");
abilityEngine.enableCastEventsFor("mending_light");
abilityEngine.enableCastEventsFor("purify");
abilityEngine.enableCastEventsFor("guardian_bond");
@@ -780,6 +791,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
passiveEvents = new games.dmg.fantasystick.events.PassiveEventBridge(eventLog::emit, Clock.systemUTC());books.attachPassiveEvents(passiveEvents);admin.attachPassiveEvents(passiveEvents);
recoveryEvents = new games.dmg.fantasystick.events.RecoveryEventBridge(eventLog::emit, Clock.systemUTC());outOfCombatRecovery.attachEvents(recoveryEvents,record->record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID)?quickerRegeneration.bonus():java.math.BigInteger.ZERO);
reviveEvents = new games.dmg.fantasystick.events.ReviveEventBridge(eventLog::emit, Clock.systemUTC());revive.attachEvents(reviveEvents);
adminRevivalEvents = new games.dmg.fantasystick.events.AdminRevivalEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Admin revival event intent failed", failure));
admin.attachAdminRevivalEvents(adminRevivalEvents);
revive.attachAdminRevivalEvents(adminRevivalEvents);
partyAuditEvents = new games.dmg.fantasystick.events.PartyEventBridge(eventLog::emit, Clock.systemUTC());partyManagement.attachEvents(partyAuditEvents);admin.attachPartyEvents(partyAuditEvents);
partyCombat.attachProtectionEvents(new games.dmg.fantasystick.events.PartyProtectionEventBridge(
eventLog::emit, Clock.systemUTC()));
@@ -795,12 +811,30 @@ public final class FantasyStickPlugin extends JavaPlugin {
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Switch deadline event intent failed", failure));
sessions.attachSwitchEvents(switchAuditEvents);
switchConfigEvents = new games.dmg.fantasystick.events.SwitchConfigEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Switch configuration event intent failed", failure));
admin.attachSwitchConfigEvents(switchConfigEvents);
roleplayAvailabilityEvents = new games.dmg.fantasystick.events.RoleplayAvailabilityEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Roleplay availability event intent failed", failure));
admin.attachRoleplayAvailabilityEvents(roleplayAvailabilityEvents);
partyCapacityEvents = new games.dmg.fantasystick.events.PartyCapacityEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Party capacity event intent failed", failure));
admin.attachPartyCapacityEvents(partyCapacityEvents);
sheetResourcePackEvents = new games.dmg.fantasystick.events.SheetResourcePackEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Sheet resource-pack event intent failed", failure));
admin.attachSheetResourcePackEvents(sheetResourcePackEvents);
sheetResourcePack.attachEvents(sheetResourcePackEvents);
var identitySessions = sessions;
identityAuditEvents = new games.dmg.fantasystick.events.CharacterIdentityEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
playerId -> identitySessions.active(playerId).map(games.dmg.fantasystick.session.EquipmentSessions.Active::session),
failure -> getLogger().log(Level.WARNING, "Character identity event intent failed", failure));
crafting.attachIdentityEvents(identityAuditEvents);
admin.attachCharacterIssueEvents(identityAuditEvents);
anvilNames.attachIdentityEvents(identityAuditEvents);
sessions.attachIdentityEvents(identityAuditEvents);
equipmentAuditEvents = new games.dmg.fantasystick.events.EquipmentEventBridge(
@@ -821,7 +855,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
id -> presentation.snapshot().get(id),sessionAuditEvents,eventLog::emit,Clock.systemUTC());
getServer().getPluginManager().registerEvents(messageAuditEvents,this);
} catch (java.io.IOException failure) {
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; nutritionAuditEvents = null; switchAuditEvents = null; identityAuditEvents = null; equipmentAuditEvents = null; sessionRecoveryAuditEvents = null; messageAuditEvents = null;
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; adminRevivalEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; nutritionAuditEvents = null; switchAuditEvents = null; switchConfigEvents = null; roleplayAvailabilityEvents = null; partyCapacityEvents = null; sheetResourcePackEvents = null; identityAuditEvents = null; equipmentAuditEvents = null; sessionRecoveryAuditEvents = null; messageAuditEvents = null;
getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure);
}
getServer().getPluginManager().registerEvents(crafting, this);
@@ -907,11 +941,36 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (failure != null) getLogger().log(Level.WARNING, "Previous nutrition event intent drain failed", failure);
if (replayNutritionEvents != null) replayNutritionEvents.replay();
});
var replayRoleplayEvents = roleplayAvailabilityEvents;
roleplayAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous roleplay event intent drain failed", failure);
if (replayRoleplayEvents != null) replayRoleplayEvents.replay();
});
var replayPartyCapacityEvents = partyCapacityEvents;
partyCapacityAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous party capacity event intent drain failed", failure);
if (replayPartyCapacityEvents != null) replayPartyCapacityEvents.replay();
});
var replaySheetPackEvents = sheetResourcePackEvents;
sheetResourcePackAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous sheet pack event intent drain failed", failure);
if (replaySheetPackEvents != null) replaySheetPackEvents.replay();
});
var replaySwitchConfigEvents = switchConfigEvents;
switchConfigAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous switch configuration intent drain failed", failure);
if (replaySwitchConfigEvents != null) replaySwitchConfigEvents.replay();
});
var replaySwitchEvents = switchAuditEvents;
switchAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous switch event intent drain failed", failure);
if (replaySwitchEvents != null) replaySwitchEvents.replay();
});
var replayAdminRevivalEvents = adminRevivalEvents;
adminRevivalAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous admin revival event intent drain failed", failure);
if (replayAdminRevivalEvents != null) replayAdminRevivalEvents.replay();
});
var replayIdentityEvents = identityAuditEvents;
identityAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous identity event intent drain failed", failure);
@@ -967,7 +1026,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingEventLog = eventLog; eventLog = null; preferenceEvents = null;
var closingNutritionAuditEvents = nutritionAuditEvents; nutritionAuditEvents = null;
var closingSwitchAuditEvents = switchAuditEvents; switchAuditEvents = null;
var closingSwitchConfigEvents = switchConfigEvents; switchConfigEvents = null;
var closingRoleplayEvents = roleplayAvailabilityEvents; roleplayAvailabilityEvents = null;
var closingPartyCapacityEvents = partyCapacityEvents; partyCapacityEvents = null;
var closingSheetPackEvents = sheetResourcePackEvents; sheetResourcePackEvents = null;
var closingIdentityAuditEvents = identityAuditEvents; identityAuditEvents = null;
var closingAdminRevivalAuditEvents = adminRevivalEvents; adminRevivalEvents = null;
var closingEquipmentAuditEvents = equipmentAuditEvents; equipmentAuditEvents = null;
var closingSessionRecoveryAuditEvents = sessionRecoveryAuditEvents; sessionRecoveryAuditEvents = null;
var closingEventCapture = eventCapture; eventCapture = null; var closingMessageAuditEvents = messageAuditEvents; messageAuditEvents = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; recoveryEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null;
@@ -1287,6 +1351,46 @@ public final class FantasyStickPlugin extends JavaPlugin {
return null;
}).thenCompose(ignored -> closingNutritionAuditEvents.drained());
});
if (closingRoleplayEvents != null) cleanup("roleplay availability event intent drain", () -> {
var settingsDrained = closingRoleplaySettings == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
: closingRoleplaySettings.drained();
roleplayAuditDrain = settingsDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Roleplay setting drain failed", failure);
closingRoleplayEvents.close();
return null;
}).thenCompose(ignored -> closingRoleplayEvents.drained());
});
if (closingPartyCapacityEvents != null) cleanup("party capacity event intent drain", () -> {
var settingsDrained = closingPartySettings == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
: closingPartySettings.drained();
partyCapacityAuditDrain = settingsDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Party setting drain failed", failure);
closingPartyCapacityEvents.close();
return null;
}).thenCompose(ignored -> closingPartyCapacityEvents.drained());
});
if (closingSheetPackEvents != null) cleanup("sheet resource-pack event intent drain", () -> {
var settingsDrained = closingSheetResourcePackSettings == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
: closingSheetResourcePackSettings.drained();
sheetResourcePackAuditDrain = settingsDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Sheet pack setting drain failed", failure);
closingSheetPackEvents.close();
return null;
}).thenCompose(ignored -> closingSheetPackEvents.drained());
});
if (closingSwitchConfigEvents != null) cleanup("switch configuration event intent drain", () -> {
var settingsDrained = closingSwitchDelaySettings == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
: closingSwitchDelaySettings.drained();
switchConfigAuditDrain = settingsDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Switch setting drain failed", failure);
closingSwitchConfigEvents.close();
return null;
}).thenCompose(ignored -> closingSwitchConfigEvents.drained());
});
if (closingSwitchAuditEvents != null) cleanup("switch event intent drain", () -> {
var sessionsDrained = closingSessions == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
@@ -1361,6 +1465,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
recipeRegistered = false; // Retain ownership for a later retry if removal failed.
});
if (closingCharacters != null) cleanup("character storage", closingCharacters::close);
if (closingAdminRevivalAuditEvents != null) cleanup("admin revival event intent drain", () -> {
var charactersDrained = closingCharacters == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
adminRevivalAuditDrain = charactersDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Character save drain failed", failure);
closingAdminRevivalAuditEvents.close();
return null;
}).thenCompose(ignored -> closingAdminRevivalAuditEvents.drained());
});
if (closingIdentityAuditEvents != null) cleanup("character identity event intent drain", () -> {
var charactersDrained = closingCharacters == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
@@ -73,7 +73,8 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
if (castEvents == null || record == null
|| !(definition.id().equals("great_sword") || definition.id().equals("longbow")
|| definition.id().equals("quickstep") || definition.id().equals("second_wind")
|| definition.id().equals("shield_bash") || definition.id().equals("hex"))
|| definition.id().equals("shield_bash") || definition.id().equals("hex")
|| definition.id().equals("inspire"))
|| !eventOwnedAbilities.contains(definition.id())) return;
resolve(castEvents.begin(input, record, definition), "refused", reason);
}
@@ -354,6 +355,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
private final Map<String, Object> castEvidence = new HashMap<>();
private boolean castPublished;
private final Map<UUID, String> observedEffects = new HashMap<>();
private final Map<UUID, Map<String, Object>> observedEndEvidence = new HashMap<>();
private final Set<AutoCloseable> owned = new HashSet<>();
private String outcome;
private Map<String, Object> resolutionEvidence = Map.of();
@@ -496,6 +498,17 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
castEvents.outgoingPrevented(audit, application, debuffedAttacker, protectedTarget,
incoming, remaining, actual, evidence);
}
@Override public void acceptedAbsorption(UUID application, Player target, Entity attacker,
double incoming, double before, double after,
double consumed, double remaining,
org.bukkit.Location targetAtHit, org.bukkit.Location attackerAtHit) {
if (castEvents == null || audit == null) return;
try {
var aided = sessions.active(target.getUniqueId()).filter(sessions::current).orElse(null);
castEvents.absorbed(audit, application, target, aided, attacker, incoming,
before, after, consumed, remaining, targetAtHit, attackerAtHit);
} catch (RuntimeException ignored) { /* Recording cannot change native absorption or support. */ }
}
@Override public void acceptedBoost(EquipmentSessions.Active attacking, Player attacker,
Entity target, double base, double bonus, double finalDamage,
java.math.BigDecimal percent) {
@@ -503,11 +516,16 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
castEvents.boosted(audit, attacking, attacker, target, base, bonus, finalDamage, percent);
}
@Override public void effectEnded(UUID application, String reason) {
effectEnded(application, reason, Map.of());
effectEnded(application, reason, observedEndEvidence.getOrDefault(application, Map.of()));
}
@Override public void effectEndEvidence(UUID application, Map<String, Object> evidence) {
if (observedEffects.containsKey(application))
observedEndEvidence.put(application, Map.copyOf(Objects.requireNonNull(evidence)));
}
@Override public void effectEnded(UUID application, String reason, Map<String, Object> evidence) {
Objects.requireNonNull(application); Objects.requireNonNull(reason); Objects.requireNonNull(evidence);
var effectId = observedEffects.remove(application);
observedEndEvidence.remove(application);
if (effectId != null && castEvents != null)
castEvents.effectEnded(audit, application, effectId, reason, evidence);
}
@@ -45,6 +45,11 @@ public interface AbilityExecution extends AutoCloseable {
/** Record only the winning owned outgoing reduction after final uncancelled native damage. */
default void acceptedOutgoingMitigation(UUID application, Entity debuffedAttacker, Entity protectedTarget,
double incoming, double remaining, double actual) { }
/** Record only observed native consumption of this execution's owned temporary health. */
default void acceptedAbsorption(UUID application, org.bukkit.entity.Player target, Entity attacker,
double incoming, double before, double after, double consumed,
double remaining, org.bukkit.Location targetAtHit,
org.bukkit.Location attackerAtHit) { }
/** Record a winning outgoing bonus only after a positive uncancelled native hit. */
void acceptedBoost(games.dmg.fantasystick.session.EquipmentSessions.Active attacking,
org.bukkit.entity.Player attacker, Entity target,
@@ -55,6 +60,8 @@ public interface AbilityExecution extends AutoCloseable {
default void effectEnded(UUID applicationId, String reason, Map<String, Object> evidence) {
effectEnded(applicationId, reason);
}
/** Capture remaining owned effect state during resource cleanup for the terminal effect record. */
default void effectEndEvidence(UUID applicationId, Map<String, Object> evidence) { }
/** Attach an observed native admission to the one pending cast record; never publish guessed velocity. */
default void castEvidence(Map<String, Object> evidence) { }
/** Attach actual final native position to an already chosen or interrupted resolution during cleanup. */
@@ -8,7 +8,38 @@ public final class Inspire implements AbilityEffect,AutoCloseable{
public AbilityDefinition definition(){var s=settings;return new AbilityDefinition("inspire","Inspire",Material.AMETHYST_SHARD,List.of("Grant "+s.baseTemporaryHealth()+" + CHR modifier temporary health for "+seconds(s.duration())+" seconds.","Range "+BigDecimal.valueOf(s.range()).stripTrailingZeros().toPlainString()+" blocks for an aimed fantasy player."),"Sneak+Use selects self; normal Use requires an aimed fantasy player.",Map.of(Stat.CHR,BigInteger.valueOf(12),Stat.WIS,BigInteger.TEN),BigInteger.ONE,AbilityDefinition.Category.BUF,AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,s.cooldown(),Map.of("base-temporary-health",new BigDecimal(s.baseTemporaryHealth()),"range",BigDecimal.valueOf(s.range()).stripTrailingZeros(),"duration-seconds",BigDecimal.valueOf(s.duration().toNanos()).movePointLeft(9)),AbilityDefinition.TargetPolicy.ALLY,this);}
@Override public boolean accepts(ControlInput input){if(closed||!(input.target() instanceof Player target))return false;if(input.player().isSneaking())return target==input.player();return target!=input.player()&&inRange(input.player(),target,settings.range());}
public static BigInteger amount(BigInteger charisma){return amount(BigInteger.valueOf(4),charisma);}static BigInteger amount(BigInteger base,BigInteger charisma){Objects.requireNonNull(base);Objects.requireNonNull(charisma);if(base.signum()<0||charisma.signum()<0)throw new IllegalArgumentException("Invalid Inspire amount");var delta=charisma.subtract(BigInteger.TEN);var divided=delta.divideAndRemainder(BigInteger.TWO);var modifier=divided[0];if(delta.signum()<0&&divided[1].signum()!=0)modifier=modifier.subtract(BigInteger.ONE);return base.add(modifier);}
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof Player target))return;var s=from(execution.definition());if(execution.input().player().isSneaking()?target!=execution.input().player():target==execution.input().player()||!inRange(execution.input().player(),target,s.range())){execution.finish();return;}var exact=amount(s.baseTemporaryHealth(),execution.character().stats().get(Stat.CHR));double nativeAmount=exact.bitLength()>1023?Double.MAX_VALUE:exact.doubleValue();if(!Double.isFinite(nativeAmount)||nativeAmount<=0){execution.finish();return;}execution.own(temporaryHealth.grant(execution,target,nativeAmount));execution.later(s.duration(),execution::finish);}
@Override public void activate(AbilityExecution execution) {
if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof Player target))return;
var s=from(execution.definition());
if(execution.input().player().isSneaking()?target!=execution.input().player():target==execution.input().player()||!inRange(execution.input().player(),target,s.range())){
execution.resolve("miss",Map.of("reason","target_ineligible_after_cooldown"));execution.finish();return;
}
var exact=amount(s.baseTemporaryHealth(),execution.character().stats().get(Stat.CHR));
double nativeAmount=exact.bitLength()>1023?Double.MAX_VALUE:exact.doubleValue();
if(!Double.isFinite(nativeAmount)||nativeAmount<=0){execution.resolve("miss",Map.of("reason","no_positive_temporary_health"));execution.finish();return;}
var application=new java.util.concurrent.atomic.AtomicReference<UUID>();
var grant=temporaryHealth.grantObserved(execution,target,nativeAmount,accepted->{
try{execution.acceptedAbsorption(application.get(),accepted.target(),accepted.attacker(),accepted.incoming(),
accepted.before(),accepted.after(),accepted.consumed(),accepted.remaining(),accepted.targetAtHit(),accepted.attackerAtHit());}
catch(RuntimeException ignored){/* Audit cannot change native absorption or party rewards. */}
},remaining->{
var id=application.get();
if(id!=null)execution.effectEndEvidence(id,Map.of("remaining_amount",
BigDecimal.valueOf(remaining).stripTrailingZeros().toPlainString()));
});
execution.own(grant);
var evidence=new LinkedHashMap<String,Object>();
evidence.put("granted_amount",BigDecimal.valueOf(nativeAmount).stripTrailingZeros().toPlainString());
evidence.put("remaining_amount",BigDecimal.valueOf(nativeAmount).stripTrailingZeros().toPlainString());
evidence.put("duration_seconds",seconds(s.duration()));
var activatedAt=execution.now();if(activatedAt!=null)evidence.put("effect_until",activatedAt.plus(s.duration()).toString());
temporaryHealth.currentSession(target).ifPresent(active->{
evidence.put("target_character_id",active.character().toString());
evidence.put("target_session_id",active.session().toString());
});
application.set(execution.effectApplied("inspire",target,evidence));
execution.later(s.duration(),()->{execution.resolve("expired");execution.finish();});
}
private static boolean inRange(Player source,Player target,double range){try{var a=source.getLocation();var b=target.getLocation();return a.getWorld()==b.getWorld()&&a.distanceSquared(b)<=range*range&&source.hasLineOfSight(target);}catch(RuntimeException invalid){return false;}}
private static Settings from(AbilityDefinition d){var p=d.parameters();return new Settings(p.get("base-temporary-health").toBigIntegerExact(),d.cooldown(),p.get("range").doubleValue(),Duration.ofNanos(p.get("duration-seconds").movePointRight(9).longValueExact()));}
private static String seconds(Duration d){return BigDecimal.valueOf(d.toNanos()).movePointLeft(9).stripTrailingZeros().toPlainString();}
@@ -53,6 +53,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
private games.dmg.fantasystick.session.DownedController downed;
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
private games.dmg.fantasystick.events.ReviveEventBridge reviveEvents;
private games.dmg.fantasystick.events.AdminRevivalEventBridge adminRevivalEvents;
private java.util.function.Supplier<Duration> downedProtection=()->Duration.ofSeconds(5);
private final Map<Inventory, View> inventories = new IdentityHashMap<>();
private final Map<UUID, View> targets = new LinkedHashMap<>();
@@ -93,6 +94,11 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
public void attachDowned(games.dmg.fantasystick.session.DownedController controller,java.util.function.Supplier<Duration> protection){if(closed||downed!=null)throw new IllegalStateException("Downed Revive already attached or closed");downed=Objects.requireNonNull(controller);downedProtection=Objects.requireNonNull(protection);}
public void attachDownedEvents(games.dmg.fantasystick.events.DownedEventBridge events){if(closed||downedEvents!=null)throw new IllegalStateException("Downed Revive events already attached or closed");downedEvents=Objects.requireNonNull(events);}
public void attachEvents(games.dmg.fantasystick.events.ReviveEventBridge events){if(closed||reviveEvents!=null)throw new IllegalStateException("Revive events already attached or closed");reviveEvents=Objects.requireNonNull(events);}
public void attachAdminRevivalEvents(games.dmg.fantasystick.events.AdminRevivalEventBridge events){if(closed||adminRevivalEvents!=null)throw new IllegalStateException("Admin revival events already attached or closed");adminRevivalEvents=Objects.requireNonNull(events);}
private void recoveryEvent(RevivalStore.Reservation reservation,CharacterRecord restored,Player target){
if(reservation.administrative()){if(adminRevivalEvents!=null)adminRevivalEvents.recovered(reservation,restored,target);}
else if(reviveEvents!=null)reviveEvents.recovered(reservation,restored,target);
}
public AbilityDefinition definition() {
return new AbilityDefinition("revive", "Revive", Material.ECHO_SHARD,
@@ -358,7 +364,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
: source != null && source.getAmount() == reservation.sourceAmount() - 1
&& tails.identify(source).filter(id -> id.deathId().equals(reservation.death())).isPresent();
if (!record.dead() && hasSheet && installFinalSheet(target, record)) {
if(reviveEvents!=null)reviveEvents.recovered(reservation,record,target);reservations.clear(reservation.character(), reservation.id()); return;
recoveryEvent(reservation,record,target);reservations.clear(reservation.character(), reservation.id()); return;
}
if (record.dead() && unchangedTail && !hasSheet) {
reservations.clear(reservation.character(), reservation.id()); return;
@@ -375,7 +381,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
.whenComplete((restored, failure) -> schedule(() -> {
if (failure != null) { report(failure); return; }
if (restored.isPresent() && installFinalSheet(target, restored.orElseThrow())) {
if(reviveEvents!=null)reviveEvents.recovered(reservation,restored.orElseThrow(),target);reservations.clear(reservation.character(), reservation.id());
recoveryEvent(reservation,restored.orElseThrow(),target);reservations.clear(reservation.character(), reservation.id());
}
}));
}
@@ -13,21 +13,83 @@ import org.bukkit.projectiles.ProjectileSource;
/** Plugin-owned temporary-health ledger layered over unrelated native absorption. */
public final class TemporaryHealth implements Listener,AutoCloseable{
public record Support(UUID target,UUID supportingCharacter,EquipmentSessions.Active aided,BigDecimal amount){}
private final class Grant implements AutoCloseable{final long sequence;final AbilityExecution execution;final Player target;double remaining;boolean closed;
Grant(AbilityExecution e,Player t,double amount){sequence=++sequences;execution=e;target=t;remaining=amount;}
@Override public void close(){if(closed)return;double before=owned(target.getUniqueId());closed=true;var list=grants.get(target.getUniqueId());if(list!=null){list.remove(this);if(list.isEmpty())grants.remove(target.getUniqueId());}double after=owned(target.getUniqueId());removeNative(target,Math.max(0,before-after));}}
public record Accepted(Player target,Entity attacker,double incoming,double before,double after,
double consumed,double remaining,org.bukkit.Location targetAtHit,
org.bukkit.Location attackerAtHit){}
private final class Grant implements AutoCloseable{final long sequence;final AbilityExecution execution;final Player target;
final Consumer<Accepted> observation;final Consumer<Double> onEnd;double remaining;boolean closed;
Grant(AbilityExecution e,Player t,double amount,Consumer<Accepted> observation,Consumer<Double> onEnd){
sequence=++sequences;execution=e;target=t;remaining=amount;this.observation=observation;this.onEnd=onEnd;}
@Override public void close(){if(closed)return;double before=owned(target.getUniqueId());closed=true;var list=grants.get(target.getUniqueId());if(list!=null){list.remove(this);if(list.isEmpty())grants.remove(target.getUniqueId());}double after=owned(target.getUniqueId());removeNative(target,Math.max(0,before-after));if(onEnd!=null)try{onEnd.accept(Math.max(0,remaining));}catch(RuntimeException ignored){/* Cleanup is independent of recording. */}}
}
private final EquipmentSessions sessions;private final PartyRoster parties;private final Consumer<Runnable> nextTick;private final Consumer<Support> supports;
private final Map<UUID,List<Grant>> grants=new HashMap<>();private long sequences;private boolean closed;
private final Map<UUID,List<Grant>> grants=new HashMap<>();
private final Set<EntityDamageEvent> pending=Collections.newSetFromMap(new IdentityHashMap<>());
private long sequences;private boolean closed;
public TemporaryHealth(EquipmentSessions sessions,PartyRoster parties,Consumer<Runnable> nextTick,Consumer<Support> supports){this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.nextTick=Objects.requireNonNull(nextTick);this.supports=Objects.requireNonNull(supports);}
public AutoCloseable grant(AbilityExecution execution,Player target,double amount){Objects.requireNonNull(execution);Objects.requireNonNull(target);if(closed||!Double.isFinite(amount)||amount<=0)throw new IllegalArgumentException("Invalid temporary health grant");double before=owned(target.getUniqueId());var grant=new Grant(execution,target,amount);grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);double after=owned(target.getUniqueId());addNative(target,Math.max(0,after-before));return grant;}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(closed||!(event.getEntity() instanceof Player target))return;var owner=strongest(target.getUniqueId());if(owner==null||!owner.execution.stillCurrent())return;double before=target.getAbsorptionAmount(),owned=owned(target.getUniqueId());if(!Double.isFinite(before)||before<=0||owned<=0)return;Entity attacker=event instanceof EntityDamageByEntityEvent by?attacker(by):null;var aided=sessions.active(target.getUniqueId()).orElse(null);boolean qualifies=aided!=null&&sessions.current(aided)&&!aided.character().equals(owner.execution.input().session().character())&&sameParty(owner.execution.input().player(),target);UUID enemy=attacker==null?null:attacker.getUniqueId();
try{nextTick.accept(()->consume(target,before,owned,owner,qualifies?aided:null,enemy));}catch(RuntimeException ignored){}}
private void consume(Player target,double before,double ownedBefore,Grant owner,EquipmentSessions.Active aided,UUID enemy){if(closed)return;double after=target.getAbsorptionAmount();double consumed=Math.min(ownedBefore,Math.max(0,before-after));if(!Double.isFinite(consumed)||consumed<=0)return;var list=grants.get(target.getUniqueId());if(list==null)return;var amount=BigDecimal.valueOf(consumed).stripTrailingZeros();owner.execution.report(AbilityReport.Type.PREVENTION,target,amount);for(var grant:List.copyOf(list))grant.remaining=Math.max(0,grant.remaining-consumed);list.removeIf(g->g.closed||g.remaining<=1e-9);if(list.isEmpty())grants.remove(target.getUniqueId());if(aided!=null&&enemy!=null)supports.accept(new Support(enemy,owner.execution.input().session().character(),aided,amount));}
public AutoCloseable grant(AbilityExecution execution,Player target,double amount){
return grantObserved(execution,target,amount,null);
}
public AutoCloseable grantObserved(AbilityExecution execution,Player target,double amount,Consumer<Accepted> observation){
return grantObserved(execution,target,amount,observation,null);
}
public AutoCloseable grantObserved(AbilityExecution execution,Player target,double amount,
Consumer<Accepted> observation,Consumer<Double> onEnd){
Objects.requireNonNull(execution);Objects.requireNonNull(target);
if(closed||!Double.isFinite(amount)||amount<=0)throw new IllegalArgumentException("Invalid temporary health grant");
double before=owned(target.getUniqueId());var grant=new Grant(execution,target,amount,observation,onEnd);
addNative(target,Math.max(0,amount-before)); // Do not retain an owned grant if the native setter refuses it.
grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);
return grant;
}
public Optional<EquipmentSessions.Active> currentSession(Player target){
return sessions.active(target.getUniqueId()).filter(sessions::current);
}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void damage(EntityDamageEvent event){
if(closed||pending.contains(event)||!(event.getEntity() instanceof Player target)
||!Double.isFinite(event.getDamage())||event.getDamage()<=0)return;
var owner=strongest(target.getUniqueId());if(owner==null||!owner.execution.stillCurrent())return;
double before=target.getAbsorptionAmount(),owned=owned(target.getUniqueId());
if(!Double.isFinite(before)||before<=0||owned<=0)return;
Entity attacker=event instanceof EntityDamageByEntityEvent by?attacker(by):null;
var targetAtHit=auditLocation(target);var attackerAtHit=auditLocation(attacker);
var aided=sessions.active(target.getUniqueId()).orElse(null);
boolean qualifies=aided!=null&&sessions.current(aided)&&!aided.character().equals(owner.execution.input().session().character())&&sameParty(owner.execution.input().player(),target);
UUID enemy=attacker==null?null:attacker.getUniqueId();double incoming=event.getDamage();
pending.add(event);
try{nextTick.accept(()->{
try{consume(event,target,before,owned,owner,qualifies?aided:null,enemy,attacker,incoming,targetAtHit,attackerAtHit);}
finally{pending.remove(event);}
});}
catch(RuntimeException ignored){pending.remove(event);}
}
private void consume(EntityDamageEvent event,Player target,double before,double ownedBefore,Grant owner,
EquipmentSessions.Active aided,UUID enemy,Entity attacker,double incoming,
org.bukkit.Location targetAtHit,org.bukkit.Location attackerAtHit){
if(closed||event.isCancelled()||!Double.isFinite(event.getDamage())||event.getDamage()<=0)return;
double after=target.getAbsorptionAmount();double consumed=Math.min(ownedBefore,Math.max(0,before-after));
if(!Double.isFinite(consumed)||consumed<=0)return;
var list=grants.get(target.getUniqueId());if(list==null||owner.closed||!list.contains(owner)||!owner.execution.stillCurrent())return;
var amount=BigDecimal.valueOf(consumed).stripTrailingZeros();
try{owner.execution.report(AbilityReport.Type.PREVENTION,target,amount);}
catch(RuntimeException ignored){/* Feedback must not affect accepted absorption. */}
for(var grant:List.copyOf(list))grant.remaining=Math.max(0,grant.remaining-consumed);
list.removeIf(g->g.closed||g.remaining<=1e-9);if(list.isEmpty())grants.remove(target.getUniqueId());
if(owner.observation!=null)try{owner.observation.accept(new Accepted(target,attacker,incoming,before,after,
consumed,Math.max(0,owner.remaining),targetAtHit,attackerAtHit));}
catch(RuntimeException ignored){/* Audit must not affect native absorption or rewards. */}
if(aided!=null&&enemy!=null)supports.accept(new Support(enemy,owner.execution.input().session().character(),aided,amount));
}
private static org.bukkit.Location auditLocation(Entity entity){
if(entity==null)return null;
try{var location=entity.getLocation();return location==null?null:location.clone();}
catch(RuntimeException unavailable){return null;}
}
private boolean sameParty(Player source,Player target){var a=parties.partyOf(source.getUniqueId()).orElse(null);var b=parties.partyOf(target.getUniqueId()).orElse(null);return a!=null&&b!=null&&a.id().equals(b.id());}
private Grant strongest(UUID target){Grant result=null;var list=grants.get(target);if(list!=null)for(var grant:List.copyOf(list))if(!grant.closed&&grant.remaining>0&&grant.execution.stillCurrent()&&(result==null||grant.remaining>result.remaining||grant.remaining==result.remaining&&grant.sequence>result.sequence))result=grant;return result;}
private double owned(UUID target){double result=0;var list=grants.get(target);if(list!=null)for(var grant:List.copyOf(list))if(!grant.closed&&grant.remaining>result)result=grant.remaining;return result;}
private static Entity attacker(EntityDamageByEntityEvent event){Entity causing=event.getDamageSource().getCausingEntity();if(causing!=null)return causing;if(event.getDamager() instanceof Projectile p){ProjectileSource shooter=p.getShooter();if(shooter instanceof Entity e)return e;}return event.getDamager();}
private static void addNative(Player target,double amount){if(amount<=0)return;double current=target.getAbsorptionAmount(),next=current+amount;if(!Double.isFinite(current)||current<0||!Double.isFinite(next))throw new IllegalStateException("Temporary health exceeds native range");target.setAbsorptionAmount(next);}
private static void removeNative(Player target,double amount){if(amount<=0)return;double current=target.getAbsorptionAmount();if(Double.isFinite(current)&&current>=0)target.setAbsorptionAmount(Math.max(0,current-amount));}
@Override public void close(){if(closed)return;for(var list:List.copyOf(grants.values()))for(var grant:List.copyOf(list))grant.close();closed=true;grants.clear();}
@Override public void close(){if(closed)return;for(var list:List.copyOf(grants.values()))for(var grant:List.copyOf(list))grant.close();closed=true;grants.clear();pending.clear();}
}
@@ -256,6 +256,39 @@ public final class AbilityCastEventBridge {
} catch (RuntimeException ignored) { /* Logging cannot alter accepted native damage. */ }
}
/** Inspire reports actual accepted native absorption, not a displayed health grant. */
public void absorbed(Cast cast, UUID effectApplication, org.bukkit.entity.Player protectedTarget,
games.dmg.fantasystick.session.EquipmentSessions.Active aided, Entity attacker,
double incoming, double before, double after, double consumed, double remaining,
Location targetAtHit, Location attackerAtHit) {
if (cast == null || effectApplication == null || !Double.isFinite(consumed) || consumed <= 0) return;
try {
var data = new LinkedHashMap<String, Object>();
UUID application = UUID.randomUUID();
data.put("application_id", application.toString());
data.put("effect_application_id", effectApplication.toString());
data.put("causation_id", cast.actionId().toString());
data.put("protected_target_id", protectedTarget.getUniqueId().toString());
data.put("protected_target_type", "PLAYER");
if (aided != null) {
data.put("protected_character_id", aided.character().toString());
data.put("protected_session_id", aided.session().toString());
}
if (targetAtHit != null) data.put("protected_location", locationData(targetAtHit));
if (attacker != null) {
data.put("attacker_entity_id", attacker.getUniqueId().toString());
data.put("attacker_entity_type", entityType(attacker));
if (attackerAtHit != null) data.put("attacker_location", locationData(attackerAtHit));
} else data.put("attacker_status", "not_applicable");
data.put("incoming_amount", decimal(incoming));
data.put("absorption_before", decimal(before));
data.put("absorption_after", decimal(after));
data.put("actual_amount", decimal(consumed));
data.put("remaining_amount", decimal(remaining));
emit(cast, "combat.prevented", "accepted", data, application);
} catch (RuntimeException ignored) { /* Native absorption and party rewards are independent of logging. */ }
}
public void boosted(Cast cast, games.dmg.fantasystick.session.EquipmentSessions.Active attacking,
org.bukkit.entity.Player attacker, Entity target, double base, double bonus,
double finalDamage, java.math.BigDecimal percent) {
@@ -0,0 +1,197 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.AdminReviveSettings;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.RevivalStore;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.command.BlockCommandSender;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Real administrator Tail restoration and acknowledged selection-timeout configuration. */
public final class AdminRevivalEventBridge implements AutoCloseable {
public record Actor(String kind, EventInput.Context context, UUID id) { }
public record Selection(Actor actor, UUID targetId, String targetName,
EventInput.Location targetLocation,
games.dmg.fantasystick.session.EquipmentSessions.Active targetSession) { }
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final FeatureEventOutbox outbox;
private final Clock clock;
private final games.dmg.fantasystick.session.EquipmentSessions sessions;
public AdminRevivalEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path data, games.dmg.fantasystick.session.EquipmentSessions sessions,
Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.sessions = Objects.requireNonNull(sessions);
outbox = new FeatureEventOutbox(data, "admin-revival-event-intents",
java.util.Set.of("character.revived", "config.changed"), sink, failures);
}
public Actor actor(CommandSender sender) {
try {
var player = sender instanceof Player value ? value : null;
var kind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
var active = player == null ? null : sessions.active(player.getUniqueId()).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
return new Actor(kind, player == null ? null : new EventInput.Context(player.getUniqueId(),
player.getName(), record == null ? null : record.id(),
record == null ? null : record.name(), record == null ? null : active.session(), safeLocation(player)),
player == null ? null : player.getUniqueId());
} catch (RuntimeException unavailable) { return null; }
}
public Selection selection(Player admin, Player target) {
try { return new Selection(actor(admin), target.getUniqueId(), target.getName(),
safeLocation(target), sessions.active(target.getUniqueId()).orElse(null)); }
catch (RuntimeException unavailable) { return null; }
}
public void revived(Selection selection, RevivalStore.Reservation reservation,
CharacterRecord dead, CharacterRecord restored, Player target) {
if (selection == null || selection.actor() == null) return;
try {
var data = revivalData(reservation, restored, target);
data.put("revision_before", dead.revision().toString());
data.put("death_time", dead.diedAt().toString());
data.put("death_age_seconds", Long.toString(Math.max(0, Duration.between(dead.diedAt(), clock.instant()).getSeconds())));
data.put("recovery_reconciliation", false);
location(data, "target_location", selection.targetLocation());
targetSession(data, selection.targetSession());
var actor = selection.actor();
data.put("admin_player_id", selection.actor().id().toString());
data.put("admin_player_name", actor.context().playerName());
if (actor.context().location() == null) data.put("actor_location_status", "unavailable");
durable("character.revived", reservation.id(), actor.context(), data);
} catch (RuntimeException ignored) { /* Audit failure cannot consume or restore a Tail. */ }
}
/** Reconciliation has the reservation's exact admin UUID, but never invents its old location or name. */
public void recovered(RevivalStore.Reservation reservation, CharacterRecord restored, Player target) {
if (!reservation.administrative()) return;
try {
var data = revivalData(reservation, restored, target);
data.put("actor_kind", "system");
data.put("admin_player_id", reservation.casterCharacter().toString());
data.put("admin_name_status", "unavailable");
data.put("actor_location_status", "unavailable");
data.put("revision_before", restored.revision().subtract(BigInteger.ONE).toString());
data.put("death_time", restored.diedAt().toString());
data.put("death_age_seconds", Long.toString(Math.max(0,
Duration.between(restored.diedAt(), reservation.createdAt()).getSeconds())));
data.put("recovery_reconciliation", true);
location(data, "target_location", safeLocation(target));
targetSession(data, sessions.active(target.getUniqueId()).orElse(null));
durable("character.revived", reservation.id(), null, data);
} catch (RuntimeException ignored) { }
}
private Map<String, Object> revivalData(RevivalStore.Reservation reservation, CharacterRecord restored, Player target) {
var data = new LinkedHashMap<String, Object>();
data.put("revival_kind", "admin_tail");
data.put("revival_operation_id", reservation.id().toString());
data.put("target_player_id", reservation.targetPlayer().toString());
data.put("target_player_name", target.getName());
data.put("character_id", restored.id().toString());
data.put("character_name", restored.name());
data.put("death_id", reservation.death().toString());
data.put("revision_after", restored.revision().toString());
data.put("tail_slot", reservation.sourceSlot());
data.put("tail_custody_id", reservation.death().toString());
data.put("sheet_slot", reservation.sourceAmount() == 1 ? reservation.sourceSlot() : reservation.destinationSlot());
data.put("source_quantity_before", Integer.toString(reservation.sourceAmount()));
data.put("source_quantity_after", Integer.toString(reservation.sourceAmount() - 1));
data.put("restored_health", restored.health().toPlainString());
data.put("maximum_health", restored.maximumHealth().toPlainString());
data.put("cooldowns_retained", true);
data.put("native_delivery", "acknowledged");
data.put("outcome", "acknowledged");
return data;
}
public void read(Actor actor, String effective) {
if (actor == null) return;
var data = config(actor);
data.put("effective", effective);
data.put("outcome", "acknowledged_read");
emit(UUID.randomUUID(), "config.read", actor.context(), data);
}
public void changed(Actor actor, String before, AdminReviveSettings.Update saved) {
if (actor == null) return;
var data = config(actor);
data.put("before", before);
data.put("after", saved.value());
data.put("effective", saved.value());
data.put("outcome", "acknowledged");
durable("config.changed", UUID.randomUUID(), actor.context(), data);
}
private static Map<String, Object> config(Actor actor) {
var data = new LinkedHashMap<String, Object>();
data.put("actor_kind", actor.kind());
data.put("key", AdminReviveSettings.KEY);
data.put("applies_to", "later_selections");
if (actor.context() == null) data.put("location_status", "not_applicable");
else if (actor.context().location() == null) data.put("location_status", "unavailable");
return data;
}
private static void targetSession(Map<String, Object> data,
games.dmg.fantasystick.session.EquipmentSessions.Active active) {
if (active == null) { data.put("target_session_status", "no_active_session"); return; }
data.put("target_session_id", active.session().toString());
data.put("target_active_character_id", active.character().toString());
data.put("target_session_status", "captured");
}
private void location(Map<String, Object> data, String key, EventInput.Location value) {
if (value == null) { data.put(key + "_status", "unavailable"); return; }
data.put(key, Map.of("world_id", value.worldId().toString(), "world_name", value.worldName(),
"x", value.x(), "y", value.y(), "z", value.z(),
"captured_at", value.capturedAt().toString(), "quality", value.quality()));
}
private EventInput.Location safeLocation(Player player) {
try { return location(player.getLocation()); }
catch (RuntimeException unavailable) { return null; }
}
private EventInput.Location location(Location value) {
try {
if (value == null || value.getWorld() == null) return null;
return new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(),
value.getX(), value.getY(), value.getZ(), clock.instant(), "exact");
} catch (RuntimeException unavailable) { return null; }
}
private void durable(String type, UUID action, EventInput.Context context, Map<String, Object> data) {
try { outbox.accepted(new EventInput(id(action, type), action, clock.instant(), type, context, data)); }
catch (RuntimeException ignored) { }
}
private void emit(UUID action, String type, EventInput.Context context, Map<String, Object> data) {
try { sink.apply(new EventInput(id(action, type), action, clock.instant(), type, context, data)); }
catch (RuntimeException ignored) { }
}
private static UUID id(UUID action, String kind) {
return UUID.nameUUIDFromBytes(("fantasystick:admin-revival:" + action + ":" + kind)
.getBytes(StandardCharsets.UTF_8));
}
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
}
@@ -14,6 +14,8 @@ import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.command.BlockCommandSender;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Captures actual identity-creating and identity-changing native actions, never previews. */
@@ -37,7 +39,7 @@ public final class CharacterIdentityEventBridge implements AutoCloseable {
this.clock = Objects.requireNonNull(clock);
this.activeSession = Objects.requireNonNull(activeSession);
this.outbox = new FeatureEventOutbox(Objects.requireNonNull(dataFolder),
"character-identity-event-intents", java.util.Set.of("character.created", "character.renamed"),
"character-identity-event-intents", java.util.Set.of("character.created", "character.renamed", "character.issued"),
sink, Objects.requireNonNull(failures));
}
@@ -74,6 +76,57 @@ public final class CharacterIdentityEventBridge implements AutoCloseable {
} catch (RuntimeException ignored) { /* Observation never authorizes a character session. */ }
}
public record Issue(UUID operation, String actorKind, EventInput.Context actor,
UUID targetPlayer, String targetName) { }
/** Capture the administrator before asynchronous saving; no second admin.action is produced. */
public Issue issue(CommandSender sender, Player target) {
try {
var player = sender instanceof Player actor ? actor : null;
var kind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
EventInput.Context context = player == null ? null : new EventInput.Context(player.getUniqueId(),
player.getName(), null, null, null, safeLocation(player));
return new Issue(UUID.randomUUID(), kind, context, target.getUniqueId(), target.getName());
} catch (RuntimeException ignored) { return null; }
}
/** The saved character and exact native inventory slot were acknowledged before this call. */
public void issued(Issue issue, Player target, CharacterRecord record, int slot) {
if (issue == null) return;
try {
var data = new LinkedHashMap<String, Object>();
data.put("actor_kind", issue.actorKind());
data.put("issue_operation_id", issue.operation().toString());
data.put("target_player_id", issue.targetPlayer().toString());
data.put("target_player_name", issue.targetName());
data.put("character_id", record.id().toString());
data.put("character_name", record.name());
data.put("revision", record.revision().toString());
data.put("generation", record.sheetGeneration().toString());
data.put("starting_level", Integer.toString(record.level()));
data.put("starting_unspent_points", record.unspentPoints().toString());
data.put("starting_health", record.health().toPlainString());
data.put("starting_coins", record.coins().toString());
var stats = new LinkedHashMap<String, Object>();
record.stats().forEach((stat, value) -> stats.put(stat.name().toLowerCase(java.util.Locale.ROOT), value.toString()));
data.put("starting_stats", stats);
data.put("delivered_slot", slot);
data.put("delivery_outcome", "native_inventory_acknowledged");
data.put("outcome", "acknowledged_delivered");
if (issue.actor() == null) data.put("actor_location_status", "not_applicable");
else if (issue.actor().location() == null) data.put("actor_location_status", "unavailable");
var targetLocation = safeLocation(target);
if (targetLocation != null) data.put("target_location", locationMap(targetLocation, "exact"));
else data.put("target_location_status", "unavailable");
durable("character.issued", issue.operation(), clock.instant(), issue.actor(), data, "issued");
} catch (RuntimeException ignored) { /* Audit cannot change acknowledged native issuance. */ }
}
private EventInput.Location safeLocation(Player player) {
try { return location(player.getLocation()); }
catch (RuntimeException unavailable) { return null; }
}
public record Craft(UUID action, Instant time, EventInput.Context actor, String route, int quantity,
UUID holderSession) { }
@@ -100,15 +100,14 @@ final class FeatureEventOutbox implements AutoCloseable {
fields.setProperty("quality", location.quality());
}
event.data().forEach((key, value) -> {
if (value instanceof String text) fields.setProperty("data." + key, "s" + text);
else if (value instanceof Boolean bool) fields.setProperty("data." + key, "b" + bool);
else if (value instanceof Integer number) fields.setProperty("data." + key, "i" + number);
else if (value instanceof Double number && Double.isFinite(number))
fields.setProperty("data." + key, "d" + number);
else if (value instanceof java.util.List<?> list && list.stream().allMatch(item -> item instanceof String text
&& (text.equals("hotbar") || text.equals("armor"))))
fields.setProperty("data." + key, "l" + String.join(",", list.stream().map(String.class::cast).toList()));
else throw new IllegalArgumentException("Unsupported feature intent value");
if (value instanceof java.util.Map<?, ?> nested) {
if (key.contains(".")) throw new IllegalArgumentException("Unsafe nested feature intent key");
nested.forEach((name, part) -> {
if (!(name instanceof String child) || child.contains("."))
throw new IllegalArgumentException("Unsafe nested feature intent key");
encode(fields, "data." + key + "." + child, part);
});
} else encode(fields, "data." + key, value);
});
var output = new ByteArrayOutputStream();
fields.store(output, "acknowledged character identity event");
@@ -127,6 +126,17 @@ final class FeatureEventOutbox implements AutoCloseable {
return event;
}
private static void encode(Properties fields, String key, Object value) {
if (value instanceof String text) fields.setProperty(key, "s" + text);
else if (value instanceof Boolean bool) fields.setProperty(key, "b" + bool);
else if (value instanceof Integer number) fields.setProperty(key, "i" + number);
else if (value instanceof Double number && Double.isFinite(number)) fields.setProperty(key, "d" + number);
else if (value instanceof java.util.List<?> list && list.stream().allMatch(item -> item instanceof String text
&& (text.equals("hotbar") || text.equals("armor"))))
fields.setProperty(key, "l" + String.join(",", list.stream().map(String.class::cast).toList()));
else throw new IllegalArgumentException("Unsupported feature intent value");
}
private EventInput read(Path path) throws IOException {
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe character event intent");
var fields = new Properties();
@@ -155,10 +165,11 @@ final class FeatureEventOutbox implements AutoCloseable {
fields.containsKey("session") ? UUID.fromString(fields.getProperty("session")) : null, location)
: null;
var data = new LinkedHashMap<String, Object>();
var nested = new LinkedHashMap<String, java.util.Map<String, Object>>();
for (var key : fields.stringPropertyNames()) if (key.startsWith("data.")) {
var value = fields.getProperty(key);
if (value.isEmpty()) throw new IOException("Invalid character event value");
data.put(key.substring(5), switch (value.charAt(0)) {
var decoded = switch (value.charAt(0)) {
case 's' -> value.substring(1);
case 'b' -> {
if (!value.equals("btrue") && !value.equals("bfalse")) throw new IOException("Invalid boolean");
@@ -178,8 +189,20 @@ final class FeatureEventOutbox implements AutoCloseable {
yield list;
}
default -> throw new IOException("Invalid feature event value type");
});
};
var name = key.substring(5);
var separator = name.indexOf('.');
if (separator < 0) data.put(name, decoded);
else {
if (separator == 0 || separator == name.length() - 1 || name.indexOf('.', separator + 1) >= 0)
throw new IOException("Invalid nested feature intent key");
nested.computeIfAbsent(name.substring(0, separator), ignored -> new LinkedHashMap<>())
.put(name.substring(separator + 1), decoded);
}
}
nested.forEach((key, value) -> {
if (data.putIfAbsent(key, value) != null) throw new IllegalArgumentException("Conflicting feature intent key");
});
return new EventInput(id, action, Instant.parse(fields.getProperty("time")), type, context, data);
} catch (RuntimeException failure) { throw new IOException("Invalid character event intent", failure); }
}
@@ -0,0 +1,109 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.PartySettings;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.command.BlockCommandSender;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Observes effective party join capacity without reading or mutating protected rosters. */
public final class PartyCapacityEventBridge implements AutoCloseable {
public record Actor(String kind, EventInput.Context context) { }
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final Clock clock;
private final EquipmentSessions sessions;
private final FeatureEventOutbox outbox;
public PartyCapacityEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path data, EquipmentSessions sessions,
Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.sessions = Objects.requireNonNull(sessions);
outbox = new FeatureEventOutbox(data, "party-capacity-event-intents",
java.util.Set.of("config.changed"), sink, failures);
}
public Actor actor(CommandSender sender) {
try {
var player = sender instanceof Player value ? value : null;
var kind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
var active = player == null ? null : sessions.active(player.getUniqueId()).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
return new Actor(kind, player == null ? null : new EventInput.Context(player.getUniqueId(),
player.getName(), record == null ? null : record.id(),
record == null ? null : record.name(), active == null ? null : active.session(),
safeLocation(player)));
} catch (RuntimeException unavailable) { return null; }
}
public void read(Actor actor, String effective) {
if (actor == null) return;
var values = common(actor);
values.put("effective", effective);
values.put("outcome", "acknowledged_read");
var operation = UUID.randomUUID();
try { sink.apply(new EventInput(id(operation, "config.read"), operation, clock.instant(),
"config.read", actor.context(), values)); }
catch (RuntimeException ignored) { /* Observation cannot change party capacity. */ }
}
public void changed(Actor actor, UUID operation, String before, String effective,
PartySettings.Update saved) {
if (actor == null) return;
try {
var values = common(actor);
values.put("operation_id", operation.toString());
values.put("policy_revision", Long.toString(saved.sequence()));
values.put("before", before);
values.put("after", saved.value());
values.put("effective", effective);
values.put("existing_rosters_grandfathered", true);
values.put("outcome", "acknowledged");
outbox.accepted(new EventInput(id(operation, "config.changed"), operation, clock.instant(),
"config.changed", actor.context(), values));
} catch (RuntimeException ignored) { /* Audit failure cannot roll back a published join limit. */ }
}
private static Map<String, Object> common(Actor actor) {
var values = new LinkedHashMap<String, Object>();
values.put("key", PartySettings.KEY);
values.put("default", PartySettings.DEFAULT);
values.put("actor_kind", actor.kind());
values.put("application_scope", "future_join_checks");
if (actor.context() == null) values.put("location_status", "not_applicable");
else if (actor.context().location() == null) values.put("location_status", "unavailable");
return values;
}
private EventInput.Location safeLocation(Player player) {
try {
Location location = player.getLocation();
if (location == null || location.getWorld() == null) return null;
var world = location.getWorld();
return new EventInput.Location(world.getUID(), world.getName(), location.getX(),
location.getY(), location.getZ(), clock.instant(), "exact");
} catch (RuntimeException unavailable) { return null; }
}
private static UUID id(UUID operation, String type) {
return UUID.nameUUIDFromBytes(("fantasystick:party-capacity:" + operation + ":" + type)
.getBytes(StandardCharsets.UTF_8));
}
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
}
@@ -27,9 +27,9 @@ public final class RecoveryEventBridge {
public void refused(Action action,String reason){emit("action.resolved",action,"refused",reason,null,null,null,null);}
public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null,null,null,null);}
public void healed(Action action,BigDecimal healthAfter,BigDecimal acceptedTotal,BigDecimal baseContribution,BigDecimal passiveContribution){emit("action.resolved",action,"accepted","acknowledged",healthAfter,acceptedTotal,baseContribution,passiveContribution);emit("combat.healed",action,"accepted","acknowledged",healthAfter,acceptedTotal,baseContribution,passiveContribution);}
private void emit(String type,Action action,String outcome,String reason,BigDecimal healthAfter,BigDecimal acceptedTotal,BigDecimal baseContribution,BigDecimal passiveContribution){var data=new LinkedHashMap<String,Object>();data.put("action_kind","out_of_combat_recovery");data.put("recovery_kind","out_of_combat");data.put("cause","recovery_timer");data.put("expected_revision",action.expectedRevision().toString());data.put("health_before",action.healthBefore().toPlainString());data.put("proposed_total",action.proposed().toPlainString());data.put("configured_base",action.configuredBase().toString());data.put("configured_quicker_regeneration_i",action.configuredQuickerRegeneration().toString());data.put("outcome",outcome);if(healthAfter!=null)data.put("health_after",healthAfter.toPlainString());if(acceptedTotal!=null){data.put("accepted_total",acceptedTotal.toPlainString());data.put("base_contribution",baseContribution.toPlainString());if(passiveContribution.signum()>0)data.put("passive_contributions",Map.of("quicker_regeneration_i",passiveContribution.toPlainString()));data.put("native_publication","acknowledged");}if(reason!=null)data.put(type.equals("combat.healed")?"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,BigDecimal healthAfter,BigDecimal acceptedTotal,BigDecimal baseContribution,BigDecimal passiveContribution){var data=new LinkedHashMap<String,Object>();data.put("action_kind","out_of_combat_recovery");data.put("recovery_kind","out_of_combat");data.put("cause","recovery_timer");data.put("expected_revision",action.expectedRevision().toString());data.put("health_before",action.healthBefore().toPlainString());data.put("proposed_total",action.proposed().toPlainString());data.put("configured_base",action.configuredBase().toString());data.put("configured_quicker_regeneration_i",action.configuredQuickerRegeneration().toString());data.put("outcome",outcome);if(action.context().location()==null)data.put("location_status","unavailable");if(healthAfter!=null)data.put("health_after",healthAfter.toPlainString());if(acceptedTotal!=null){data.put("accepted_total",acceptedTotal.toPlainString());data.put("base_contribution",baseContribution.toPlainString());if(passiveContribution.signum()>0)data.put("passive_contributions",Map.of("quicker_regeneration_i",passiveContribution.toPlainString()));data.put("native_publication","acknowledged");}if(reason!=null)data.put(type.equals("combat.healed")?"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 EventInput.Context context(Player player,EquipmentSessions.Active active,String name){return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),location(player.getLocation()));}
private EventInput.Context context(Player player,EquipmentSessions.Active active,String name){EventInput.Location captured;try{captured=location(player.getLocation());}catch(RuntimeException unavailable){captured=null;}return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),captured);}
private EventInput.Location location(Location value){return new EventInput.Location(value.getWorld().getUID(),value.getWorld().getName(),value.getX(),value.getY(),value.getZ(),clock.instant(),"exact");}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:recovery:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
}
@@ -0,0 +1,127 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.RoleplaySettings;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.command.BlockCommandSender;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** A policy publication requests ordinary exits; it never attests their later receipt completion. */
public final class RoleplayAvailabilityEventBridge implements AutoCloseable {
public record Actor(String kind, EventInput.Context context) { }
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final Clock clock;
private final EquipmentSessions sessions;
private final FeatureEventOutbox outbox;
public RoleplayAvailabilityEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path data, EquipmentSessions sessions,
Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.sessions = Objects.requireNonNull(sessions);
outbox = new FeatureEventOutbox(data, "roleplay-availability-event-intents",
java.util.Set.of("config.changed", "game.availability_changed"), sink, failures);
}
public Actor actor(CommandSender sender) {
try {
var player = sender instanceof Player value ? value : null;
var kind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
var active = player == null ? null : sessions.active(player.getUniqueId()).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
return new Actor(kind, player == null ? null : new EventInput.Context(player.getUniqueId(),
player.getName(), record == null ? null : record.id(),
record == null ? null : record.name(), record == null ? null : active.session(),
safeLocation(player)));
} catch (RuntimeException unavailable) { return null; }
}
public void read(Actor actor, boolean disabled) {
if (actor == null) return;
var values = common(actor);
values.put("effective", disabled);
values.put("available", !disabled);
values.put("outcome", "acknowledged_read");
var operation = UUID.randomUUID();
try { sink.apply(new EventInput(id(operation, "config.read"), operation, clock.instant(),
"config.read", actor.context(), values)); }
catch (RuntimeException ignored) { /* An observation cannot change availability. */ }
}
public void changed(Actor actor, UUID operation, boolean before, RoleplaySettings.Update saved,
EquipmentSessions.DisableSummary exits) {
if (actor == null) return;
try {
var config = common(actor);
config.put("operation_id", operation.toString());
config.put("policy_revision", Long.toString(saved.sequence()));
config.put("before", before);
config.put("after", saved.disabled());
config.put("effective", saved.disabled());
config.put("outcome", "acknowledged");
durable(operation, "config.changed", actor.context(), config);
var availability = common(actor);
availability.put("origin_admin_action_id", operation.toString());
availability.put("policy_revision", Long.toString(saved.sequence()));
availability.put("available_before", !before);
availability.put("available_after", !saved.disabled());
availability.put("exit_requested_count", exits == null ? 0 : exits.exitRequested());
availability.put("exit_request_accepted_count", exits == null ? 0 : exits.exitRequested());
availability.put("exit_native_restored_pending_receipt_count",
exits == null ? 0 : exits.nativeRestoredPendingReceipt());
availability.put("exit_confirmed_count", 0);
availability.put("exit_failed_count", exits == null ? 0 : exits.exitFailed());
availability.put("exit_pending_count", exits == null ? 0 : exits.exitPending());
availability.put("pending_entry_refused_count", exits == null ? 0 : exits.entryRefused());
availability.put("receipt_state", "not_yet_observed");
availability.put("outcome", "policy_published_exit_requests_only");
durable(operation, "game.availability_changed", actor.context(), availability);
} catch (RuntimeException ignored) { /* Logging failure cannot reverse policy or reissue exits. */ }
}
private static Map<String, Object> common(Actor actor) {
var values = new LinkedHashMap<String, Object>();
values.put("key", RoleplaySettings.KEY);
values.put("actor_kind", actor.kind());
values.put("application_scope", "global_and_active_sessions");
if (actor.context() == null) values.put("location_status", "not_applicable");
else if (actor.context().location() == null) values.put("location_status", "unavailable");
return values;
}
private EventInput.Location safeLocation(Player player) {
try {
Location location = player.getLocation();
if (location == null || location.getWorld() == null) return null;
var world = location.getWorld();
return new EventInput.Location(world.getUID(), world.getName(), location.getX(),
location.getY(), location.getZ(), clock.instant(), "exact");
} catch (RuntimeException unavailable) { return null; }
}
private void durable(UUID operation, String type, EventInput.Context actor, Map<String, Object> values) {
outbox.accepted(new EventInput(id(operation, type), operation, clock.instant(), type, actor, values));
}
private static UUID id(UUID operation, String type) {
return UUID.nameUUIDFromBytes(("fantasystick:roleplay-availability:" + operation + ":" + type)
.getBytes(StandardCharsets.UTF_8));
}
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
}
@@ -0,0 +1,178 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.SheetResourcePackSettings;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.command.BlockCommandSender;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerResourcePackStatusEvent;
/** Pack events describe native offers and callbacks, never actual client rendering or gameplay authority. */
public final class SheetResourcePackEventBridge implements AutoCloseable {
public record Actor(String kind, EventInput.Context context) { }
public record Request(UUID action, UUID pack, String assetIdentity, String sha1) { }
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final Clock clock;
private final EquipmentSessions sessions;
private final FeatureEventOutbox outbox;
public SheetResourcePackEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path data, EquipmentSessions sessions,
Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.sessions = Objects.requireNonNull(sessions);
outbox = new FeatureEventOutbox(data, "sheet-resource-pack-event-intents",
java.util.Set.of("config.changed", "resource_pack.delivery_requested",
"resource_pack.delivery_resolved"), sink, failures);
}
public Actor actor(CommandSender sender) {
try {
var player = sender instanceof Player value ? value : null;
var kind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
var active = player == null ? null : sessions.active(player.getUniqueId()).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
return new Actor(kind, player == null ? null : new EventInput.Context(player.getUniqueId(),
player.getName(), record == null ? null : record.id(),
record == null ? null : record.name(), active == null ? null : active.session(),
safeLocation(player)));
} catch (RuntimeException unavailable) { return null; }
}
public void read(Actor actor, String key, String value) {
if (actor == null) return;
var values = config(actor, key);
values.put("effective", safeValue(key, value));
values.put("outcome", "acknowledged_read");
var action = UUID.randomUUID();
try { sink.apply(new EventInput(id(action, "config.read"), action, clock.instant(),
"config.read", actor.context(), values)); }
catch (RuntimeException ignored) { /* A read cannot change a delivery. */ }
}
public void changed(Actor actor, UUID action, String key, String before, String effective,
SheetResourcePackSettings.Update saved) {
if (actor == null) return;
try {
var values = config(actor, key);
values.put("operation_id", action.toString());
values.put("policy_revision", Long.toString(saved.sequence()));
values.put("before", safeValue(key, before));
values.put("after", safeValue(key, key.equals(SheetResourcePackSettings.URL_KEY)
? saved.snapshot().url() : saved.snapshot().sha1()));
values.put("effective", safeValue(key, effective));
values.put("outcome", "acknowledged_before_optional_redelivery");
durable(action, "config.changed", actor.context(), values);
} catch (RuntimeException ignored) { /* An audit failure cannot reverse a paired setting. */ }
}
public Request requested(Player player, UUID pack, String url, String sha1, String trigger,
boolean delivered) {
var action = UUID.randomUUID();
var request = new Request(action, pack, identity(url), sha1);
try {
var values = new LinkedHashMap<String, Object>();
values.put("pack_id", pack.toString());
values.put("asset_identity", request.assetIdentity());
values.put("sha1", sha1);
values.put("trigger", trigger);
values.put("prompt", "Optional Fantasy Stick first-person sheet presentation");
values.put("required", false);
values.put("outcome", delivered ? "native_offer_returned" : "native_offer_failed");
var context = playerContext(player);
values.put("location_quality", context.location() == null ? "unavailable" : context.location().quality());
if (context.location() == null) values.put("location_status", "unavailable");
durable(action, "resource_pack.delivery_requested", context, values);
} catch (RuntimeException ignored) { /* Pack requests and gameplay never depend on the audit writer. */ }
return request;
}
public void resolved(Player player, Request request, PlayerResourcePackStatusEvent.Status status) {
try {
var outcome = status.name().toLowerCase(Locale.ROOT);
if (status == PlayerResourcePackStatusEvent.Status.FAILED_RELOAD) outcome = "reload_failed";
var values = new LinkedHashMap<String, Object>();
values.put("pack_id", request.pack().toString());
values.put("asset_identity", request.assetIdentity());
values.put("sha1", request.sha1());
values.put("origin_delivery_action_id", request.action().toString());
values.put("status", outcome);
values.put("reason", "client_reported_status");
values.put("callback_time", clock.instant().toString());
var context = playerContext(player);
values.put("location_quality", context.location() == null ? "unavailable" : context.location().quality());
if (context.location() == null) values.put("location_status", "unavailable");
durable(request.action(), "resource_pack.delivery_resolved", context, values, status.name());
} catch (RuntimeException ignored) { /* A status cannot authorize gameplay. */ }
}
private static Map<String, Object> config(Actor actor, String key) {
var values = new LinkedHashMap<String, Object>();
values.put("key", key);
values.put("actor_kind", actor.kind());
values.put("application_scope", "later_optional_redelivery");
if (actor.context() == null) values.put("location_status", "not_applicable");
else if (actor.context().location() == null) values.put("location_status", "unavailable");
return values;
}
/** A URL's query can contain credentials; only a one-way identity escapes the policy boundary. */
public static String safeValue(String key, String value) {
return SheetResourcePackSettings.URL_KEY.equals(key) ? identity(value) : value;
}
private static String identity(String url) {
try {
return "sha256:" + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(url.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException unavailable) { throw new IllegalStateException(unavailable); }
}
private EventInput.Context playerContext(Player player) {
return new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
safeLocation(player));
}
private EventInput.Location safeLocation(Player player) {
try {
Location location = player.getLocation();
if (location == null || location.getWorld() == null) return null;
var world = location.getWorld();
return new EventInput.Location(world.getUID(), world.getName(), location.getX(),
location.getY(), location.getZ(), clock.instant(), "exact");
} catch (RuntimeException unavailable) { return null; }
}
private void durable(UUID action, String type, EventInput.Context context, Map<String, Object> values) {
durable(action, type, context, values, type);
}
private void durable(UUID action, String type, EventInput.Context context, Map<String, Object> values,
String discriminator) {
outbox.accepted(new EventInput(id(action, discriminator), action, clock.instant(), type, context, values));
}
private static UUID id(UUID action, String discriminator) {
return UUID.nameUUIDFromBytes(("fantasystick:sheet-pack:" + action + ":" + discriminator)
.getBytes(StandardCharsets.UTF_8));
}
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
}
@@ -0,0 +1,110 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.SwitchDelaySettings;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.command.BlockCommandSender;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Observes the registered administrator's acknowledged switch policy; never changes deadlines. */
public final class SwitchConfigEventBridge implements AutoCloseable {
public record Actor(String kind, EventInput.Context context) { }
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final Clock clock;
private final EquipmentSessions sessions;
private final FeatureEventOutbox outbox;
public SwitchConfigEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path data, EquipmentSessions sessions,
Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.sessions = Objects.requireNonNull(sessions);
outbox = new FeatureEventOutbox(data, "switch-config-event-intents", java.util.Set.of("config.changed"),
sink, failures);
}
public Actor actor(CommandSender sender) {
try {
var player = sender instanceof Player value ? value : null;
var kind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
var active = player == null ? null : sessions.active(player.getUniqueId()).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
return new Actor(kind, player == null ? null : new EventInput.Context(player.getUniqueId(),
player.getName(), record == null ? null : record.id(),
record == null ? null : record.name(), record == null ? null : active.session(),
safeLocation(player)));
} catch (RuntimeException unavailable) { return null; }
}
public void read(Actor actor, String effective) {
if (actor == null) return;
var data = common(actor);
data.put("effective", effective);
data.put("outcome", "acknowledged_read");
var action = UUID.randomUUID();
try { sink.apply(new EventInput(id(action, "config.read"), action, clock.instant(),
"config.read", actor.context(), data)); }
catch (RuntimeException ignored) { /* Observation cannot change the policy. */ }
}
public void changed(Actor actor, UUID operation, String before, String effective,
SwitchDelaySettings.Update saved) {
if (actor == null) return;
try {
var data = common(actor);
data.put("operation_id", operation.toString());
data.put("store_sequence", Long.toString(saved.sequence()));
data.put("before", before);
data.put("after", saved.value());
data.put("effective", effective);
data.put("zero_disabled", saved.delay().isZero());
data.put("outcome", "acknowledged");
outbox.accepted(new EventInput(id(operation, "config.changed"), operation, clock.instant(),
"config.changed", actor.context(), data));
} catch (RuntimeException ignored) { /* A failed audit cannot roll back an effective setting. */ }
}
private static Map<String, Object> common(Actor actor) {
var data = new LinkedHashMap<String, Object>();
data.put("key", SwitchDelaySettings.KEY);
data.put("default", SwitchDelaySettings.DEFAULT);
data.put("application_scope", "future_exit");
data.put("existing_deadlines_unchanged", true);
data.put("actor_kind", actor.kind());
if (actor.context() == null) data.put("location_status", "not_applicable");
else if (actor.context().location() == null) data.put("location_status", "unavailable");
return data;
}
private EventInput.Location safeLocation(Player player) {
try {
Location location = player.getLocation();
if (location == null || location.getWorld() == null) return null;
var world = location.getWorld();
return new EventInput.Location(world.getUID(), world.getName(), location.getX(),
location.getY(), location.getZ(), clock.instant(), "exact");
} catch (RuntimeException unavailable) { return null; }
}
private static UUID id(UUID action, String kind) {
return UUID.nameUUIDFromBytes(("fantasystick:switch-config:" + action + ":" + kind)
.getBytes(StandardCharsets.UTF_8));
}
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
}
@@ -72,11 +72,12 @@ public final class InspireSettings implements AutoCloseable {
public Inspire.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
if (update.sequence() <= publishedSequence) return false;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
return true;
}
public CompletionStage<Update> set(String key, String value) {
@@ -1,7 +1,12 @@
package games.dmg.fantasystick.presentation;
import games.dmg.fantasystick.persistence.SheetResourcePackSettings;
import games.dmg.fantasystick.events.SheetResourcePackEventBridge;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.Objects;
import java.util.UUID;
import java.util.function.BiConsumer;
@@ -9,6 +14,7 @@ import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerResourcePackStatusEvent;
/** Optional cosmetic pack delivery. Status never authorizes or cancels gameplay. */
@@ -16,36 +22,71 @@ public final class SheetResourcePack implements Listener, AutoCloseable {
public static final UUID ID = UUID.fromString("0d53e627-f13b-5a49-b8d5-882d3f4122bb");
private final SheetResourcePackSettings settings;
private final BiConsumer<UUID, Throwable> failures;
private final Map<UUID, Offer> offers = new HashMap<>();
private final Map<UUID, Player> recipients = new HashMap<>();
private SheetResourcePackEventBridge events;
private boolean closed;
private record Offer(SheetResourcePackEventBridge.Request request,
Set<PlayerResourcePackStatusEvent.Status> statuses) { }
public SheetResourcePack(SheetResourcePackSettings settings, BiConsumer<UUID, Throwable> failures) {
this.settings = Objects.requireNonNull(settings);
this.failures = Objects.requireNonNull(failures);
}
public void deliverAll(Collection<? extends Player> players) {
if (closed) return;
for (var player : players) if (player != null && player.isOnline()) deliver(player);
public void attachEvents(SheetResourcePackEventBridge bridge) {
if (closed || events != null) throw new IllegalStateException("Sheet pack events unavailable");
events = Objects.requireNonNull(bridge);
}
public void deliver(Player player) {
public void deliverAll(Collection<? extends Player> players) { deliverAll(players, "startup"); }
public void deliverAll(Collection<? extends Player> players, String trigger) {
if (closed) return;
for (var player : players) if (player != null && player.isOnline()) deliver(player, trigger);
}
public void deliver(Player player) { deliver(player, "explicit"); }
private void deliver(Player player, String trigger) {
if (closed || !player.isOnline()) return;
var playerId = player.getUniqueId();
var prior = offers.get(playerId);
var pack = UUID.randomUUID(); // Native callbacks expose only this ID, not a usable hash.
var url = settings.url(); var sha1 = settings.sha1();
try {
player.addResourcePack(ID, settings.url(), java.util.HexFormat.of().parseHex(settings.sha1()),
player.addResourcePack(pack, url, java.util.HexFormat.of().parseHex(sha1),
"Optional Fantasy Stick first-person sheet presentation", false);
if (events != null) offers.put(playerId, new Offer(
events.requested(player, pack, url, sha1, trigger, true), new HashSet<>()));
recipients.put(playerId, player);
if (prior != null) try { player.removeResourcePack(prior.request().pack()); }
catch (RuntimeException failure) { report(playerId, failure); }
} catch (RuntimeException failure) {
report(player.getUniqueId(), failure);
player.sendMessage("Fantasy Stick cosmetic resource pack could not be offered; gameplay is unchanged.");
if (events != null) events.requested(player, pack, url, sha1, trigger, false);
report(playerId, failure);
try { player.sendMessage("Fantasy Stick cosmetic resource pack could not be offered; gameplay is unchanged."); }
catch (RuntimeException feedbackFailure) { report(playerId, feedbackFailure); }
}
}
@EventHandler public void join(PlayerJoinEvent event) { deliver(event.getPlayer()); }
@EventHandler public void join(PlayerJoinEvent event) { deliver(event.getPlayer(), "join"); }
@EventHandler public void quit(PlayerQuitEvent event) {
var id = event.getPlayer().getUniqueId(); offers.remove(id); recipients.remove(id);
}
@EventHandler public void status(PlayerResourcePackStatusEvent event) {
if (closed || !ID.equals(event.getID())) return;
if (closed) return;
var offer = offers.get(event.getPlayer().getUniqueId());
if (offer == null || !offer.request().pack().equals(event.getID())) return;
if (offer.statuses().add(event.getStatus()) && events != null)
events.resolved(event.getPlayer(), offer.request(), event.getStatus());
switch (event.getStatus()) {
case DECLINED, FAILED_DOWNLOAD, INVALID_URL, FAILED_RELOAD, DISCARDED ->
event.getPlayer().sendMessage("Fantasy Stick cosmetic resource pack is unavailable; gameplay is unchanged.");
case DECLINED, FAILED_DOWNLOAD, INVALID_URL, FAILED_RELOAD, DISCARDED -> {
try { event.getPlayer().sendMessage("Fantasy Stick cosmetic resource pack is unavailable; gameplay is unchanged."); }
catch (RuntimeException feedbackFailure) { report(event.getPlayer().getUniqueId(), feedbackFailure); }
}
default -> { /* Acceptance/loading is cosmetic and needs no gameplay callback. */ }
}
}
@@ -55,5 +96,13 @@ public final class SheetResourcePack implements Listener, AutoCloseable {
catch (RuntimeException ignored) { /* Diagnostics cannot make a cosmetic pack mandatory. */ }
}
@Override public void close() { closed = true; }
@Override public void close() {
closed = true;
offers.forEach((id, offer) -> {
var player = recipients.get(id);
if (player != null) try { player.removeResourcePack(offer.request().pack()); }
catch (RuntimeException failure) { report(id, failure); }
});
offers.clear(); recipients.clear(); events = null;
}
}
@@ -9,6 +9,7 @@ import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Persists one fresh character before handing its authentic sheet to a still-valid native inventory slot. */
@@ -22,6 +23,11 @@ public final class AdminCharacterIssuer implements AutoCloseable {
private final Consumer<Failure> failures;
private final ConcurrentHashMap<UUID, Attempt> pending = new ConcurrentHashMap<>();
private volatile boolean closed;
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityEvents;
public void attachIdentityEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events) {
identityEvents = Objects.requireNonNull(events);
}
public AdminCharacterIssuer(CharacterStorage storage, Supplier<CharacterRecord> allocate,
Consumer<Runnable> nextTick, Consumer<Failure> failures) {
@@ -32,12 +38,13 @@ public final class AdminCharacterIssuer implements AutoCloseable {
}
/** Must be entered on the server thread. Completion is delivered on that thread. */
public void issue(Player player, Consumer<Outcome> response) {
Objects.requireNonNull(player); Objects.requireNonNull(response);
public void issue(CommandSender sender, Player player, Consumer<Outcome> response) {
Objects.requireNonNull(sender); Objects.requireNonNull(player); Objects.requireNonNull(response);
if (closed || !player.isOnline()) { response.accept(Outcome.STALE); return; }
int slot = player.getInventory().firstEmpty();
if (slot < 0) { response.accept(Outcome.FULL); return; }
var attempt = new Attempt(player, slot, response);
var audit = identityEvents;
var attempt = new Attempt(player, slot, response, audit == null ? null : audit.issue(sender, player));
if (pending.putIfAbsent(attempt.playerId, attempt) != null) { response.accept(Outcome.BUSY); return; }
try {
attempt.record = allocate.get();
@@ -74,9 +81,15 @@ public final class AdminCharacterIssuer implements AutoCloseable {
|| !claimed.orElseThrow().getFirst().id().equals(attempt.record.id())) {
abandon(attempt); respond(attempt, Outcome.RETAINED); return;
}
var savedRecord = claimed.orElseThrow().getFirst();
try {
var sheet = new CharacterSheets().create(attempt.record);
var sheets = new CharacterSheets();
var sheet = sheets.create(savedRecord);
attempt.player.getInventory().setItem(attempt.slot, sheet);
if (!sheets.identify(attempt.player.getInventory().getItem(attempt.slot))
.filter(savedRecord.id()::equals).isPresent()) {
respond(attempt, Outcome.RETAINED); return;
}
attempt.delivered = true;
} catch (RuntimeException uncertain) {
report(attempt, "delivery", uncertain);
@@ -85,6 +98,8 @@ public final class AdminCharacterIssuer implements AutoCloseable {
}
try { attempt.player.updateInventory(); }
catch (RuntimeException syncFailure) { report(attempt, "sync", syncFailure); }
var events = identityEvents;
if (events != null) events.issued(attempt.issue, attempt.player, savedRecord, attempt.slot);
respond(attempt, Outcome.DELIVERED);
}
@@ -114,8 +129,12 @@ public final class AdminCharacterIssuer implements AutoCloseable {
private static final class Attempt {
final Player player; final UUID playerId; final int slot; final Consumer<Outcome> response;
final games.dmg.fantasystick.events.CharacterIdentityEventBridge.Issue issue;
final java.util.concurrent.atomic.AtomicBoolean abandoned = new java.util.concurrent.atomic.AtomicBoolean();
CharacterRecord record; CharacterStorage.CraftPreparation preparation; volatile boolean delivered;
Attempt(Player player, int slot, Consumer<Outcome> response) { this.player=player; this.playerId=player.getUniqueId(); this.slot=slot; this.response=response; }
Attempt(Player player, int slot, Consumer<Outcome> response,
games.dmg.fantasystick.events.CharacterIdentityEventBridge.Issue issue) {
this.player=player; this.playerId=player.getUniqueId(); this.slot=slot; this.response=response; this.issue=issue;
}
}
}
@@ -36,6 +36,12 @@ public final class AdminRevival implements Listener, AutoCloseable {
private final Consumer<Runnable> nextTick; private final Scheduler scheduler; private final PopupFactory popups;
private final Consumer<Throwable> failures; private final TailBooks tails=new TailBooks();private final CharacterSheets sheets=new CharacterSheets();
private final Map<Inventory,View> views=new IdentityHashMap<>();private boolean closed;
private games.dmg.fantasystick.events.AdminRevivalEventBridge auditEvents;
public void attachEvents(games.dmg.fantasystick.events.AdminRevivalEventBridge events) {
if (closed || auditEvents != null) throw new IllegalStateException("Admin revival events already attached");
auditEvents = Objects.requireNonNull(events);
}
private final class View implements AutoCloseable {
final Player admin,target;final Inventory inventory;final Map<Integer,Choice> choices;final Instant deadline;final AutoCloseable expiry;boolean selecting,finished;
@@ -58,9 +64,9 @@ public final class AdminRevival implements Listener, AutoCloseable {
@EventHandler(priority=EventPriority.MONITOR) public void quit(org.bukkit.event.player.PlayerQuitEvent event){for(var view:List.copyOf(views.values()))if(view.admin==event.getPlayer()||view.target==event.getPlayer())view.close();}
private void confirm(View view,Choice choice){if(!view.current()||!unchanged(view.target,choice.candidate)){view.close();return;}characters.find(choice.record.id()).whenComplete((found,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival could not recheck that Tail; no item changed.");view.close();return;}if(!view.current()||found.isEmpty()||!eligible(found.orElseThrow(),choice.candidate.identity)||!unchanged(view.target,choice.candidate)){view.admin.sendMessage("That Tail selection is stale; no item changed.");view.close();return;}reserve(view,choice.candidate,found.orElseThrow());}));}
private void reserve(View view,Candidate candidate,CharacterRecord dead){var source=view.target.getInventory().getItem(candidate.slot);int destination=source.getAmount()==1?-1:emptySlot(view.target,candidate.slot);if(source.getAmount()>1&&destination<0){view.admin.sendMessage("Admin revival needs one empty direct inventory slot to preserve copied Tails.");view.close();return;}var reservation=new RevivalStore.Reservation(UUID.randomUUID(),view.admin.getUniqueId(),view.target.getUniqueId(),dead.id(),dead.deathId(),clock.instant().truncatedTo(ChronoUnit.SECONDS),candidate.slot,source.getAmount(),destination,true);reservations.reserve(reservation).whenComplete((saved,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival reservation failed; no item changed.");view.close();return;}if(saved.isEmpty()||!view.current()||!unchanged(view.target,candidate)){view.admin.sendMessage("Admin revival became stale after reservation; no item changed.");view.close();return;}deliver(view,dead,reservation);}));}
private void reserve(View view,Candidate candidate,CharacterRecord dead){var source=view.target.getInventory().getItem(candidate.slot);int destination=source.getAmount()==1?-1:emptySlot(view.target,candidate.slot);if(source.getAmount()>1&&destination<0){view.admin.sendMessage("Admin revival needs one empty direct inventory slot to preserve copied Tails.");view.close();return;}var reservation=new RevivalStore.Reservation(UUID.randomUUID(),view.admin.getUniqueId(),view.target.getUniqueId(),dead.id(),dead.deathId(),clock.instant().truncatedTo(ChronoUnit.SECONDS),candidate.slot,source.getAmount(),destination,true);var audit=auditEvents==null?null:auditEvents.selection(view.admin,view.target);reservations.reserve(reservation).whenComplete((saved,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival reservation failed; no item changed.");view.close();return;}if(saved.isEmpty()||!view.current()||!unchanged(view.target,candidate)){view.admin.sendMessage("Admin revival became stale after reservation; no item changed.");view.close();return;}deliver(view,dead,reservation,audit);}));}
private static int emptySlot(Player target,int source){for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40||slot==source)continue;var item=target.getInventory().getItem(slot);if(item==null||item.isEmpty())return slot;}return-1;}
private void deliver(View view,CharacterRecord dead,RevivalStore.Reservation reservation){try{var provisional=new CharacterRecord(dead.id(),dead.name(),dead.revision(),dead.stats(),dead.level(),dead.experience(),dead.unspentPoints(),dead.maximumHealth(),dead.learned(),dead.loadout(),dead.passives(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt(),dead.sheetGeneration());var sheet=sheets.create(provisional);var source=view.target.getInventory().getItem(reservation.sourceSlot());if(reservation.sourceAmount()==1)view.target.getInventory().setItem(reservation.sourceSlot(),sheet);else{view.target.getInventory().setItem(reservation.destinationSlot(),sheet);var remaining=source.clone();remaining.setAmount(reservation.sourceAmount()-1);view.target.getInventory().setItem(reservation.sourceSlot(),remaining);}view.target.updateInventory();}catch(RuntimeException uncertain){report(uncertain);view.admin.sendMessage("Admin revival delivery is uncertain; retained proof requires recovery.");view.close();return;}characters.restoreDeathRecordAdministratively(reservation.character(),reservation.death(),NativeHealthLimit.read()).whenComplete((restored,failure)->schedule(()->{if(failure!=null||restored.isEmpty()){if(failure!=null)report(failure);view.admin.sendMessage("The Tail changed, but restoration is pending proof-based recovery.");view.close();return;}if(installFinal(view.target,restored.orElseThrow())){reservations.clear(reservation.character(),reservation.id()).exceptionally(error->{report(error);return false;});view.admin.sendMessage("Administratively restored "+restored.orElseThrow().name()+" for "+view.target.getName()+".");view.target.sendMessage("An administrator restored character sheet: "+restored.orElseThrow().name()+".");}else view.admin.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");view.close();}));}
private void deliver(View view,CharacterRecord dead,RevivalStore.Reservation reservation,games.dmg.fantasystick.events.AdminRevivalEventBridge.Selection audit){try{var provisional=new CharacterRecord(dead.id(),dead.name(),dead.revision(),dead.stats(),dead.level(),dead.experience(),dead.unspentPoints(),dead.maximumHealth(),dead.learned(),dead.loadout(),dead.passives(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt(),dead.sheetGeneration());var sheet=sheets.create(provisional);var source=view.target.getInventory().getItem(reservation.sourceSlot());if(reservation.sourceAmount()==1)view.target.getInventory().setItem(reservation.sourceSlot(),sheet);else{view.target.getInventory().setItem(reservation.destinationSlot(),sheet);var remaining=source.clone();remaining.setAmount(reservation.sourceAmount()-1);view.target.getInventory().setItem(reservation.sourceSlot(),remaining);}view.target.updateInventory();}catch(RuntimeException uncertain){report(uncertain);view.admin.sendMessage("Admin revival delivery is uncertain; retained proof requires recovery.");view.close();return;}characters.restoreDeathRecordAdministratively(reservation.character(),reservation.death(),NativeHealthLimit.read()).whenComplete((restored,failure)->schedule(()->{if(failure!=null||restored.isEmpty()){if(failure!=null)report(failure);view.admin.sendMessage("The Tail changed, but restoration is pending proof-based recovery.");view.close();return;}if(installFinal(view.target,restored.orElseThrow())){if(auditEvents!=null)auditEvents.revived(audit,reservation,dead,restored.orElseThrow(),view.target);reservations.clear(reservation.character(),reservation.id()).exceptionally(error->{report(error);return false;});view.admin.sendMessage("Administratively restored "+restored.orElseThrow().name()+" for "+view.target.getName()+".");view.target.sendMessage("An administrator restored character sheet: "+restored.orElseThrow().name()+".");}else view.admin.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");view.close();}));}
private boolean installFinal(Player target,CharacterRecord record){int found=-1;for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40)continue;if(sheets.identify(target.getInventory().getItem(slot)).filter(record.id()::equals).isEmpty())continue;if(found>=0)return false;found=slot;}if(found<0)return false;target.getInventory().setItem(found,sheets.create(record));target.updateInventory();return true;}
private void schedule(Runnable action){try{nextTick.accept(action);}catch(RuntimeException rejected){report(rejected);}}
private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
@@ -68,11 +68,15 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.ability.Meteor meteor;
private AdminCharacterIssuer characterIssuer;
private AdminRevival adminRevival;
private games.dmg.fantasystick.events.AdminRevivalEventBridge adminRevivalEvents;
private AdminExperienceGrant experienceGrant;
private games.dmg.fantasystick.persistence.AdminReviveSettings adminReviveSettings;
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
private games.dmg.fantasystick.events.SwitchConfigEventBridge switchConfigEvents;
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
private games.dmg.fantasystick.events.RoleplayAvailabilityEventBridge roleplayAvailabilityEvents;
private games.dmg.fantasystick.persistence.PartySettings partySettings;
private games.dmg.fantasystick.events.PartyCapacityEventBridge partyCapacityEvents;
private games.dmg.fantasystick.persistence.PartyEconomySettings partyEconomySettings;
private games.dmg.fantasystick.persistence.GearSettings gearSettings;
private games.dmg.fantasystick.persistence.CoinSettings coinSettings;
@@ -84,6 +88,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.party.CombatExperience combatExperience;
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
private games.dmg.fantasystick.events.SheetResourcePackEventBridge sheetResourcePackEvents;
private games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings outOfCombatRecoverySettings;
private OutOfCombatRecovery outOfCombatRecovery;
private games.dmg.fantasystick.persistence.QuickerRegenerationSettings quickerRegenerationSettings;
@@ -336,8 +341,20 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
}
public void attachSwitchDelay(games.dmg.fantasystick.persistence.SwitchDelaySettings settings){if(closed||switchDelaySettings!=null)throw new IllegalStateException("Switch delay already attached or command closed");switchDelaySettings=Objects.requireNonNull(settings);}
public void attachSwitchConfigEvents(games.dmg.fantasystick.events.SwitchConfigEventBridge events){
if(closed||switchDelaySettings==null||switchConfigEvents!=null)throw new IllegalStateException("Switch config events unavailable");
switchConfigEvents=Objects.requireNonNull(events);
}
public void attachRoleplaySettings(games.dmg.fantasystick.persistence.RoleplaySettings settings){if(closed||roleplaySettings!=null)throw new IllegalStateException("Roleplay settings already attached or command closed");roleplaySettings=Objects.requireNonNull(settings);}
public void attachRoleplayAvailabilityEvents(games.dmg.fantasystick.events.RoleplayAvailabilityEventBridge events){
if(closed||roleplaySettings==null||roleplayAvailabilityEvents!=null)throw new IllegalStateException("Roleplay availability events unavailable");
roleplayAvailabilityEvents=Objects.requireNonNull(events);
}
public void attachPartySettings(games.dmg.fantasystick.persistence.PartySettings settings){if(closed||partySettings!=null)throw new IllegalStateException("Party settings already attached or command closed");partySettings=Objects.requireNonNull(settings);}
public void attachPartyCapacityEvents(games.dmg.fantasystick.events.PartyCapacityEventBridge events){
if(closed||partySettings==null||partyCapacityEvents!=null)throw new IllegalStateException("Party capacity events unavailable");
partyCapacityEvents=Objects.requireNonNull(events);
}
public void attachPartyEconomySettings(games.dmg.fantasystick.persistence.PartyEconomySettings settings){if(closed||partyEconomySettings!=null)throw new IllegalStateException("Party economy settings already attached or command closed");partyEconomySettings=Objects.requireNonNull(settings);}
public void attachGearSettings(games.dmg.fantasystick.persistence.GearSettings settings){if(closed||gearSettings!=null)throw new IllegalStateException("Gear settings already attached or command closed");gearSettings=Objects.requireNonNull(settings);}
public void attachCoinSettings(games.dmg.fantasystick.persistence.CoinSettings settings){if(closed||coinSettings!=null)throw new IllegalStateException("Coin settings already attached or command closed");coinSettings=Objects.requireNonNull(settings);}
@@ -355,6 +372,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
}
public void attachDungeons(games.dmg.fantasystick.dungeon.DungeonCommands commands){if(closed||dungeonCommands!=null)throw new IllegalStateException("Dungeons already attached or command closed");dungeonCommands=Objects.requireNonNull(commands);}
public void attachSheetResourcePack(games.dmg.fantasystick.persistence.SheetResourcePackSettings settings,games.dmg.fantasystick.presentation.SheetResourcePack delivery){if(closed||sheetResourcePackSettings!=null)throw new IllegalStateException("Sheet resource pack already attached or command closed");sheetResourcePackSettings=Objects.requireNonNull(settings);sheetResourcePack=Objects.requireNonNull(delivery);}
public void attachSheetResourcePackEvents(games.dmg.fantasystick.events.SheetResourcePackEventBridge events){
if(closed||sheetResourcePackSettings==null||sheetResourcePackEvents!=null)
throw new IllegalStateException("Sheet pack events unavailable");
sheetResourcePackEvents=Objects.requireNonNull(events);
}
public void attachOutOfCombatRecovery(games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings settings,
OutOfCombatRecovery recovery) {
if (closed || outOfCombatRecoverySettings != null) throw new IllegalStateException("Out-of-combat recovery already attached or command closed");
@@ -398,11 +420,23 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
adminRevival=Objects.requireNonNull(revival);adminReviveSettings=Objects.requireNonNull(settings);
}
public void attachAdminRevivalEvents(games.dmg.fantasystick.events.AdminRevivalEventBridge events) {
if (closed || adminRevivalEvents != null || adminRevival == null)
throw new IllegalStateException("Admin revival event bridge unavailable");
adminRevivalEvents = Objects.requireNonNull(events);
adminRevival.attachEvents(events);
}
public void attachCharacterIssuer(AdminCharacterIssuer issuer) {
if (closed || characterIssuer != null) throw new IllegalStateException("Character issuer already attached or command closed");
characterIssuer = Objects.requireNonNull(issuer);
}
public void attachCharacterIssueEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events) {
if (closed || characterIssuer == null) throw new IllegalStateException("Character issuer unavailable");
characterIssuer.attachIdentityEvents(events);
}
public void attachCharacterBooks(games.dmg.fantasystick.book.CharacterBookController books,
games.dmg.fantasystick.persistence.CharacterStorage storage) {
if (closed || characterBooks != null) throw new IllegalStateException("Character books already attached or command closed");
@@ -959,7 +993,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
sender.sendMessage("Character issue refused: target must name one online player. No character or item was created."); return;
}
var target = matches.getFirst();
characterIssuer.issue(target, outcome -> {
characterIssuer.issue(sender, target, outcome -> {
if (closed || !sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(switch (outcome) {
@@ -1358,7 +1392,40 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
});
}
private void inspireConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+inspireSettings.value(key)+" (applies to future Inspire casts only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.InspireSettings.Update> operation;try{operation=inspireSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Inspire setting is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null){inspire.configure(saved.settings());abilities.replace(inspire.definition());inspireSettings.publish(saved);}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+key+"="+saved.value()+" for future Inspire casts; active effects and deadlines are unchanged.":"Inspire configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void inspireConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = inspireSettings.value(key);
if (args.length == 2) {
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "inspire", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Inspire casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.InspireSettings.Update> operation;
try { operation = inspireSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Inspire setting is invalid; the effective value is unchanged."); return;
}
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-" + key, failure);
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
String effectiveBefore = failure == null ? inspireSettings.value(key) : null;
boolean applied = failure == null && inspireSettings.publish(saved);
if (applied) {
inspire.configure(inspireSettings.settings()); abilities.replace(inspire.definition());
if (abilityConfigEvents != null)
abilityConfigEvents.changed(actor, "inspire", key, effectiveBefore, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure != null
? "Inspire configuration save failed; the effective setting is unchanged."
: applied ? "Saved " + key + "=" + saved.value() + " for future Inspire casts; active effects and deadlines are unchanged."
: "Saved request was superseded; effective " + key + "=" + inspireSettings.value(key) + ".");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void mendingLightConfiguration(CommandSender sender, String[] args) {
String key = args[1];
@@ -1776,7 +1843,40 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
});
}
private void sheetResourcePackConfiguration(CommandSender sender,String[] args){if(sheetResourcePackSettings==null){configurationUsage(sender);return;}String key=args[1];if(args.length==2){sender.sendMessage(key+"="+sheetResourcePackSettings.value(key));return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SheetResourcePackSettings.Update> operation;try{operation=sheetResourcePackSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Sheet resource-pack value is invalid; use an absolute HTTPS URL or 40 hexadecimal SHA-1 characters.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-sheet-resource-pack",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=sheetResourcePackSettings.publish(saved);if(applied)sheetResourcePack.deliverAll(server.getOnlinePlayers());}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Sheet resource-pack configuration save failed; effective values are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+sheetResourcePackSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+sheetResourcePackSettings.value(key)+"; optional delivery was requested for online players.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void sheetResourcePackConfiguration(CommandSender sender,String[] args){
if(sheetResourcePackSettings==null){configurationUsage(sender);return;}
String key=args[1]; var audit=sheetResourcePackEvents;
if(args.length==2){
var effective=sheetResourcePackSettings.value(key);
if(audit!=null)audit.read(audit.actor(sender),key,effective);
sender.sendMessage(key+"="+effective); return;
}
var actor=audit==null?null:audit.actor(sender);
var action=UUID.randomUUID();
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SheetResourcePackSettings.Update> operation;
try{operation=sheetResourcePackSettings.set(key,args[2]);}
catch(IllegalArgumentException invalid){sender.sendMessage("Sheet resource-pack value is invalid; use an absolute HTTPS URL or 40 hexadecimal SHA-1 characters.");return;}
operation.whenComplete((saved,failure)->{
if(failure!=null)report(null,"config-sheet-resource-pack",failure);
if(closed)return;
try{nextTick.accept(()->{
if(closed)return;
boolean applied=false;
if(failure==null){
var before=sheetResourcePackSettings.value(key);
applied=sheetResourcePackSettings.publish(saved);
var effective=sheetResourcePackSettings.value(key);
if(audit!=null&&applied&&!before.equals(effective))
audit.changed(actor,action,key,before,effective,saved);
if(applied)sheetResourcePack.deliverAll(server.getOnlinePlayers(),"config_change");
}
if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
if(failure!=null)sender.sendMessage("Sheet resource-pack configuration save failed; effective values are unchanged.");
else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+sheetResourcePackSettings.value(key)+".");
else sender.sendMessage("Saved "+key+"="+sheetResourcePackSettings.value(key)+"; optional delivery was requested for online players.");
});}catch(RuntimeException rejected){report(null,"config-response",rejected);}
});
}
private void partyEconomyConfiguration(CommandSender sender,String[] args){String key=args[1],before=partyEconomySettings.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(partyEvents!=null)partyEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+" (applies to future party transactions only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartyEconomySettings.Update> operation;try{operation=partyEconomySettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party price is invalid; enter a whole number 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&&partyEconomySettings.publish(saved);if(applied&&partyEvents!=null)partyEvents.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("Party price save failed; the effective price is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+partyEconomySettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future party transactions.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
@@ -1820,13 +1920,144 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
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&&adventureQuestSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Adventure quest configuration save failed; effective values are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+adventureQuestSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for later catalogs, acceptances or attempts; active state is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});
}
private void partyConfiguration(CommandSender sender,String[] args){if(partySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.PartySettings.KEY+"="+partySettings.value()+" (existing parties are retained)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartySettings.Update> operation;try{operation=partySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party maximum is invalid; enter a positive whole number.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-party",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null)applied=partySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Party configuration save failed; the effective limit and rosters are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+partySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+saved.value()+"; existing members are retained.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void partyConfiguration(CommandSender sender,String[] args){
if(partySettings==null){configurationUsage(sender);return;}
var audit=partyCapacityEvents;
if(args.length==2){
var effective=partySettings.value();
if(audit!=null)audit.read(audit.actor(sender),effective);
sender.sendMessage(games.dmg.fantasystick.persistence.PartySettings.KEY+"="+effective+" (existing parties are retained)");
return;
}
var actor=audit==null?null:audit.actor(sender);
var operationId=java.util.UUID.randomUUID();
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartySettings.Update> operation;
try{operation=partySettings.set(args[2]);}
catch(IllegalArgumentException invalid){sender.sendMessage("Party maximum is invalid; enter a positive whole number.");return;}
operation.whenComplete((saved,failure)->{
if(failure!=null)report(null,"config-party",failure);
if(closed)return;
try{nextTick.accept(()->{
if(closed)return;
boolean applied=false;
if(failure==null){
var before=partySettings.value();
applied=partySettings.publish(saved);
var effective=partySettings.value();
if(audit!=null&&applied&&!before.equals(effective))
audit.changed(actor,operationId,before,effective,saved);
}
if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
if(failure!=null)sender.sendMessage("Party configuration save failed; the effective limit and rosters are unchanged.");
else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+partySettings.value()+".");
else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+saved.value()+"; existing members are retained.");
});}catch(RuntimeException rejected){report(null,"config-response",rejected);}
});
}
private void roleplayConfiguration(CommandSender sender,String[] args){if(roleplaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value());return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RoleplaySettings.Update> operation;try{operation=roleplaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Usage: /fstickadmin config roleplay-disabled [true|false]");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-roleplay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=roleplaySettings.publish(saved);if(applied&&saved.disabled())sessions.disableRoleplay();}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Roleplay configuration save failed; policy and sessions are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+saved.disabled()+(saved.disabled()?"; ordinary exits were requested for active sessions.":"; normal sheet activation is available."));});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void roleplayConfiguration(CommandSender sender,String[] args){
if(roleplaySettings==null){configurationUsage(sender);return;}
var audit=roleplayAvailabilityEvents;
if(args.length==2){
var disabled=roleplaySettings.disabled();
if(audit!=null)audit.read(audit.actor(sender),disabled);
sender.sendMessage(games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+disabled);
return;
}
var actor=audit==null?null:audit.actor(sender);
var operationId=java.util.UUID.randomUUID();
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RoleplaySettings.Update> operation;
try{operation=roleplaySettings.set(args[2]);}
catch(IllegalArgumentException invalid){sender.sendMessage("Usage: /fstickadmin config roleplay-disabled [true|false]");return;}
operation.whenComplete((saved,failure)->{
if(failure!=null)report(null,"config-roleplay",failure);
if(closed)return;
try{nextTick.accept(()->{
if(closed)return;
boolean applied=false;
if(failure==null){
var before=roleplaySettings.disabled();
applied=roleplaySettings.publish(saved);
EquipmentSessions.DisableSummary exits=null;
if(applied&&saved.disabled())exits=sessions.disableRoleplay();
if(audit!=null&&applied&&before!=saved.disabled())audit.changed(actor,operationId,before,saved,exits);
}
if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
if(failure!=null)sender.sendMessage("Roleplay configuration save failed; policy and sessions are unchanged.");
else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value()+".");
else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+saved.disabled()
+(saved.disabled()?"; ordinary exits were requested for active sessions."
:"; normal sheet activation is available."));
});}catch(RuntimeException rejected){report(null,"config-response",rejected);}
});
}
private void switchDelayConfiguration(CommandSender sender,String[] args){if(switchDelaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+switchDelaySettings.value()+" (new exits use this delay; existing deadlines are unchanged)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SwitchDelaySettings.Update> operation;try{operation=switchDelaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Character switch delay is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-switch-delay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null)switchDelaySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+saved.value()+"; new exits use it and existing deadlines are unchanged.":"Character switch delay save failed; the effective value is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void switchDelayConfiguration(CommandSender sender,String[] args){
if(switchDelaySettings==null){configurationUsage(sender);return;}
var audit=switchConfigEvents;
if(args.length==2){
var effective=switchDelaySettings.value();
if(audit!=null)audit.read(audit.actor(sender),effective);
sender.sendMessage(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+effective
+" (new exits use this delay; existing deadlines are unchanged)");
return;
}
var actor=audit==null?null:audit.actor(sender);
var operationId=java.util.UUID.randomUUID();
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SwitchDelaySettings.Update> operation;
try{operation=switchDelaySettings.set(args[2]);}
catch(IllegalArgumentException invalid){sender.sendMessage("Character switch delay is invalid; the effective value is unchanged.");return;}
operation.whenComplete((saved,failure)->{
if(failure!=null)report(null,"config-switch-delay",failure);
if(closed)return;
try{nextTick.accept(()->{
if(closed)return;
if(failure==null){
var before=switchDelaySettings.value();
switchDelaySettings.publish(saved);
var effective=switchDelaySettings.value();
if(audit!=null&&!before.equals(effective))audit.changed(actor,operationId,before,effective,saved);
}
if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+saved.value()
+"; new exits use it and existing deadlines are unchanged."
:"Character switch delay save failed; the effective value is unchanged.");
});}catch(RuntimeException rejected){report(null,"config-response",rejected);}
});
}
private void adminReviveConfiguration(CommandSender sender,String[] args){if(adminReviveSettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+adminReviveSettings.value()+" (applies to later admin revival selections only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.AdminReviveSettings.Update> operation;try{operation=adminReviveSettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Admin revival timeout is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-admin-revive",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null)adminReviveSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+saved.value()+" for later selections; open selections retain their deadline.":"Admin revival configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void adminReviveConfiguration(CommandSender sender,String[] args){
if(adminReviveSettings==null){configurationUsage(sender);return;}
var audit = adminRevivalEvents;
if(args.length==2){
var value=adminReviveSettings.value();
if(audit!=null)audit.read(audit.actor(sender),value);
sender.sendMessage(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+value
+" (applies to later admin revival selections only)");
return;
}
var actor=audit==null?null:audit.actor(sender);
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.AdminReviveSettings.Update> operation;
try{operation=adminReviveSettings.set(args[2]);}
catch(IllegalArgumentException invalid){sender.sendMessage("Admin revival timeout is invalid; the effective value is unchanged.");return;}
operation.whenComplete((saved,failure)->{
if(failure!=null)report(null,"config-admin-revive",failure);
if(closed)return;
try{nextTick.accept(()->{
if(closed)return;
if(failure==null){
var before=adminReviveSettings.value();
adminReviveSettings.publish(saved);
if(audit!=null && !before.equals(adminReviveSettings.value()))
audit.changed(actor,before,saved);
}
if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+saved.value()
+" for later selections; open selections retain their deadline."
:"Admin revival configuration save failed; the effective setting is unchanged.");
});}catch(RuntimeException rejected){report(null,"config-response",rejected);}
});
}
private void adventureConfiguration(CommandSender sender, String[] args) {
var action = sessionRecoveryEvents == null ? null : sessionRecoveryEvents.capture(sender, null);
@@ -1345,12 +1345,30 @@ public final class EquipmentSessions implements AutoCloseable {
});
}
/** A synchronous exit request is not proof of an acknowledged character/receipt restoration. */
public record DisableSummary(int entryRefused, int exitRequested, int nativeRestoredPendingReceipt,
int exitFailed, int exitPending) { }
/** Cancel pending activation and route every active player through ordinary restoration. */
public synchronized void disableRoleplay() {
for (var attempt : java.util.List.copyOf(pending.values())) {
public synchronized DisableSummary disableRoleplay() {
var pendingEntries = java.util.List.copyOf(pending.values());
var activeSessions = java.util.List.copyOf(active.values());
for (var attempt : pendingEntries) {
refuse(attempt, "ROLEPLAY_DISABLED", "Fantasy roleplaying was disabled before activation completed.");
}
for (var attempt : java.util.List.copyOf(active.values())) exit(attempt.player);
int nativeRestored = 0, failed = 0;
for (var attempt : activeSessions) {
try {
var result = exit(attempt.player);
if (result == EquipmentStorage.RestoreResult.RESTORED) nativeRestored++;
else if (result != EquipmentStorage.RestoreResult.NOT_FOUND) failed++;
} catch (RuntimeException uncertain) {
diagnostic(attempt, uncertain);
failed++;
}
}
return new DisableSummary(pendingEntries.size(), activeSessions.size(), nativeRestored,
failed, activeSessions.size() - failed);
}
public synchronized void cancelEntry(UUID player) {
@@ -0,0 +1,281 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import games.dmg.fantasystick.items.CharacterSheets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Registered administrator issuance is not an event success until native delivery is acknowledged. */
class AdminCharacterIssueEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredConsoleIssueExportsOnlyAcknowledgedSavedAndDeliveredSheet() throws Exception {
UUID issued;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(target.core.id, target.player);
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"give", target.accountName});
assertTrue(sender.messages.isEmpty(), "A queued save is not delivery");
assertTrue(issuedEvents().isEmpty(), "A queued save cannot export issuance");
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
var completion = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(completion);
completion.run(); completion.run();
assertTrue(sender.messages.getLast().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);
issued = sheets.identify(target.core.inventory.getItem(delivered[0])).orElseThrow();
var saved = storage.find(issued).get(5, TimeUnit.SECONDS).orElseThrow();
for (int i = 0; i < 200 && issuedEvents().isEmpty(); i++) Thread.sleep(10);
var matching = issuedEvents().stream().filter(event -> event.getAsJsonObject("data")
.get("character_id").getAsString().equals(issued.toString())).toList();
assertEquals(1, matching.size(), "One native delivery has one stable success record");
var event = matching.getFirst(); var payload = event.getAsJsonObject("data");
assertEquals("console", payload.get("actor_kind").getAsString());
assertEquals(target.core.id.toString(), payload.get("target_player_id").getAsString());
assertEquals(target.accountName, payload.get("target_player_name").getAsString());
assertEquals(issued.toString(), payload.get("character_id").getAsString());
assertEquals(saved.name(), payload.get("character_name").getAsString());
assertEquals(saved.revision().toString(), payload.get("revision").getAsString());
assertEquals(saved.unspentPoints().toString(), payload.get("starting_unspent_points").getAsString());
assertEquals(saved.stats().size(), payload.getAsJsonObject("starting_stats").size());
assertEquals(delivered[0], payload.get("delivered_slot").getAsInt());
assertEquals("acknowledged_delivered", payload.get("outcome").getAsString());
assertEquals("not_applicable", payload.get("actor_location_status").getAsString());
assertEquals(target.world.getUID().toString(),
payload.getAsJsonObject("target_location").get("world_id").getAsString());
assertEquals(payload.get("issue_operation_id").getAsString(), payload.get("action_id").getAsString());
assertEquals(1, matching.stream().map(item -> item.get("id").getAsString()).distinct().count());
}
var beforeRestart = issuedEvents().stream().filter(event -> event.getAsJsonObject("data")
.get("character_id").getAsString().equals(issued.toString())).toList();
assertEquals(1, beforeRestart.size());
try (var restarted = new FantasyStickLifecycleNativeTest.Fixture(data)) {
restarted.plugin.setEnabled(true);
restarted.plugin.setEnabled(false);
}
var afterRestart = issuedEvents().stream().filter(event -> event.getAsJsonObject("data")
.get("character_id").getAsString().equals(issued.toString())).toList();
assertEquals(1, afterRestart.stream().map(item -> item.get("id").getAsString()).distinct().count());
}
@Test void registeredPlayerAdministratorCapturesActualActorAndTargetLocations() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
admin.accountName = "Administrator";
var target = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(admin.core.id, admin.player); f.players.put(target.core.id, target.player);
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"give", target.accountName});
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
var completion = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(completion); completion.run();
for (int i = 0; i < 200 && issuedEvents().isEmpty(); i++) Thread.sleep(10);
var events = issuedEvents(); assertEquals(1, events.size());
var data = events.getFirst().getAsJsonObject("data");
assertEquals(admin.core.id.toString(), data.get("player_id").getAsString());
assertEquals("player", data.get("actor_kind").getAsString());
assertEquals(admin.world.getUID().toString(), data.getAsJsonObject("location").get("world_id").getAsString());
assertEquals(target.world.getUID().toString(), data.getAsJsonObject("target_location").get("world_id").getAsString());
}
}
@Test void fullInventoryAndDeniedCommandCannotExportIssuance() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(target.core.id, target.player);
var sender = new EquipmentAdminCommandNativeTest.Sender();
sender.allowed = false;
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"give", target.accountName});
sender.allowed = true;
for (int slot = 0; slot < 36; slot++)
target.core.inventory.setItem(slot, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"give", target.accountName});
assertTrue(sender.messages.getLast().contains("no inventory space"));
assertTrue(f.tasks.isEmpty());
f.plugin.setEnabled(false);
}
assertTrue(issuedEvents().isEmpty());
}
@Test void failedSaveDisconnectAndCloseCannotExportIssuance() throws Exception {
for (var route : List.of("save", "disconnect", "close")) {
var dir = data.resolve(route); Files.createDirectories(dir);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(dir)) {
f.plugin.setEnabled(true);
var target = new FantasyStickLifecycleNativeTest.Actor(dir);
f.players.put(target.core.id, target.player);
var sender = new EquipmentAdminCommandNativeTest.Sender();
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var worker = (java.util.concurrent.ExecutorService)
FantasyStickLifecycleNativeTest.Fixture.field(storage, "worker");
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
worker.execute(() -> {
entered.countDown();
try { release.await(5, TimeUnit.SECONDS); }
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
});
try {
assertTrue(entered.await(5, TimeUnit.SECONDS));
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"give", target.accountName});
if (route.equals("save")) {
var issuer = FantasyStickLifecycleNativeTest.Fixture.field(f.command.getExecutor(), "characterIssuer");
var pending = (java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(issuer, "pending");
var attempt = pending.values().iterator().next();
var record = (games.dmg.fantasystick.persistence.CharacterRecord)
FantasyStickLifecycleNativeTest.Fixture.field(attempt, "record");
Files.createDirectory(dir.resolve("characters").resolve(record.id() + ".yml"));
}
} finally { release.countDown(); }
storage.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback);
if (route.equals("disconnect")) target.core.online = false;
if (route.equals("close")) f.plugin.setEnabled(false);
callback.run();
assertFalse(sender.messages.stream().anyMatch(message -> message.startsWith("Issued one")));
if (!route.equals("close")) f.plugin.setEnabled(false);
}
assertTrue(issuedEvents(dir).isEmpty(), route + " is not acknowledged delivery");
}
}
@Test void unavailableEventStoreCannotUndoAcknowledgedNativeIssue() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
Files.writeString(data.resolve("character-identity-event-intents"), "obstructed");
var target = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(target.core.id, target.player);
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"give", target.accountName});
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
assertTrue(sender.messages.getLast().contains("Issued one new character sheet"));
var sheets = new CharacterSheets();
assertEquals(1, java.util.stream.IntStream.range(0, 36).filter(slot ->
sheets.identify(target.core.inventory.getItem(slot)).filter(id -> !id.equals(target.core.record.id()))
.isPresent()).count());
f.plugin.setEnabled(false);
}
assertTrue(issuedEvents().isEmpty(), "A failed audit store never fabricates an exported record");
}
@Test void staleReservedSlotAndFailedNativeSetterCannotClaimAnIssuedSheet() throws Exception {
for (var route : List.of("slot", "native")) {
var dir = data.resolve(route); Files.createDirectories(dir);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(dir)) {
f.plugin.setEnabled(true);
var target = new FantasyStickLifecycleNativeTest.Actor(dir);
f.players.put(target.core.id, target.player);
var sender = new EquipmentAdminCommandNativeTest.Sender();
if (route.equals("native")) {
var inventory = mock(org.bukkit.inventory.PlayerInventory.class);
when(inventory.firstEmpty()).thenReturn(1);
doThrow(new IllegalStateException("native setter rejected"))
.when(inventory).setItem(eq(1), any(org.bukkit.inventory.ItemStack.class));
doReturn(inventory).when(target.player).getInventory();
}
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"give", target.accountName});
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback);
if (route.equals("slot"))
target.core.inventory.setItem(1, new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND));
callback.run();
assertFalse(sender.messages.stream().anyMatch(message -> message.startsWith("Issued one")));
f.plugin.setEnabled(false);
}
assertTrue(issuedEvents(dir).isEmpty(), route + " must not claim confirmed delivery");
}
}
@Test void rejectedEventStoreRetainsTheSameIssuanceIdentityForReplayWithoutRedelivery() throws Exception {
var target = mock(org.bukkit.entity.Player.class);
var playerId = UUID.randomUUID();
when(target.getUniqueId()).thenReturn(playerId); when(target.getName()).thenReturn("Target");
var sender = mock(org.bukkit.command.CommandSender.class);
var record = new games.dmg.fantasystick.crafting.CharacterFactory()
.create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
var initial = new games.dmg.fantasystick.events.CharacterIdentityEventBridge(
ignored -> java.util.concurrent.CompletableFuture.completedFuture(
games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED),
java.time.Clock.systemUTC(), data, ignored -> java.util.Optional.empty(), failure -> fail(failure));
var issue = initial.issue(sender, target);
assertNotNull(issue);
initial.issued(issue, target, record, 1);
initial.close(); initial.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
try (var pending = Files.list(data.resolve("character-identity-event-intents"))) {
assertEquals(1, pending.filter(path -> path.toString().endsWith(".pending")).count());
}
var exported = new java.util.concurrent.CopyOnWriteArrayList<games.dmg.fantasystick.events.EventInput>();
var replay = new games.dmg.fantasystick.events.CharacterIdentityEventBridge(event -> {
exported.add(event);
return java.util.concurrent.CompletableFuture.completedFuture(
games.dmg.fantasystick.events.LocalEventLog.Delivery.EXPORTED);
}, java.time.Clock.systemUTC(), data, ignored -> java.util.Optional.empty(), failure -> fail(failure));
replay.replay(); replay.close(); replay.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
assertEquals(1, exported.size());
assertEquals(issue.operation(), exported.getFirst().actionId());
assertEquals(issue.operation().toString(), exported.getFirst().data().get("issue_operation_id"));
assertEquals(playerId.toString(), exported.getFirst().data().get("target_player_id"));
assertEquals(1, exported.getFirst().data().get("delivered_slot"));
assertEquals(record.stats().size(), ((java.util.Map<?, ?>) exported.getFirst().data().get("starting_stats")).size());
assertEquals("unavailable", exported.getFirst().data().get("target_location_status"));
var again = new games.dmg.fantasystick.events.CharacterIdentityEventBridge(event -> {
exported.add(event);
return java.util.concurrent.CompletableFuture.completedFuture(
games.dmg.fantasystick.events.LocalEventLog.Delivery.EXPORTED);
}, java.time.Clock.systemUTC(), data, ignored -> java.util.Optional.empty(), failure -> fail(failure));
again.replay(); again.close(); again.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
assertEquals(1, exported.size(), "Acknowledged export removes the recoverable intent");
}
private List<JsonObject> issuedEvents() throws Exception { return issuedEvents(data); }
private List<JsonObject> issuedEvents(Path root) throws Exception {
var results = new ArrayList<JsonObject>();
if (!Files.isDirectory(root.resolve("events"))) return results;
try (var days = Files.list(root.resolve("events"))) {
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}"))
.toList()) {
try (var segments = Files.list(day)) {
for (var path : segments.filter(file -> file.getFileName().toString().startsWith("events-")
&& file.getFileName().toString().endsWith(".jsonl")).toList()) {
for (var line : Files.readAllLines(path)) {
if (!line.isBlank()) {
var event = JsonParser.parseString(line).getAsJsonObject();
if (event.get("type").getAsString().endsWith("character.issued")) results.add(event);
}
}
}
}
}
}
return results;
}
}
@@ -0,0 +1,351 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.InventoryAction;
import org.bukkit.event.inventory.InventoryType;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class AdminRevivalEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredSelectionPublishesOneAcknowledgedAdminTailRestorationNotMenuOrRequest() throws Exception {
UUID character;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
doReturn("Administrator").when(admin.player).getName();
doReturn("Target").when(target.player).getName();
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
f.players.put(admin.core.id, admin.player); f.players.put(target.core.id, target.player);
f.activate(target);
var active = ((games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"))
.active(target.core.id).orElseThrow();
var dead = AdminRevivalNativeTest.dead(data, Instant.now().minusSeconds(172800)
.truncatedTo(ChronoUnit.SECONDS));
character = dead.id();
target.core.inventory.setItem(10, new games.dmg.fantasystick.items.TailBooks().create(dead));
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"revive", "Target"});
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.find(dead.id()).get(5, TimeUnit.SECONDS); f.tick();
assertEquals(1, admin.inventories.size());
assertTrue(revivals().isEmpty(), "Opening the selection is not a revival");
var popup = admin.inventories.getFirst();
var view = mock(org.bukkit.inventory.InventoryView.class);
when(view.getTopInventory()).thenReturn(popup);
when(view.getBottomInventory()).thenReturn(admin.core.inventory);
when(view.getPlayer()).thenReturn(admin.player);
var click = new org.bukkit.event.inventory.InventoryClickEvent(view, InventoryType.SlotType.CONTAINER,
0, ClickType.LEFT, InventoryAction.PICKUP_ALL);
f.dispatcher.accept(click); assertTrue(click.isCancelled());
PartyBookNativeTest.drain(f);
var reservations = (games.dmg.fantasystick.persistence.RevivalStore)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "revivals");
reservations.find(dead.id()).join();
for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(f);
var saved = storage.find(dead.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertFalse(saved.dead());
assertEquals(dead.id(), new games.dmg.fantasystick.items.CharacterSheets()
.identify(target.core.inventory.getItem(10)).orElseThrow());
for (int i = 0; i < 200 && revivals().isEmpty(); i++) Thread.sleep(10);
var own = revivals(); assertEquals(1, own.size());
var event = own.getFirst(); var payload = event.getAsJsonObject("data");
assertEquals("admin_tail", payload.get("revival_kind").getAsString());
assertEquals(admin.core.id.toString(), payload.get("player_id").getAsString());
assertEquals(target.core.id.toString(), payload.get("target_player_id").getAsString());
assertEquals(dead.id().toString(), payload.get("character_id").getAsString());
assertEquals(dead.deathId().toString(), payload.get("death_id").getAsString());
assertEquals(dead.revision().toString(), payload.get("revision_before").getAsString());
assertEquals(saved.revision().toString(), payload.get("revision_after").getAsString());
assertEquals(10, payload.get("tail_slot").getAsInt());
assertEquals(active.session().toString(), payload.get("target_session_id").getAsString());
assertEquals(active.character().toString(), payload.get("target_active_character_id").getAsString());
assertEquals("acknowledged", payload.get("outcome").getAsString());
assertEquals(admin.world.getUID().toString(), payload.getAsJsonObject("location").get("world_id").getAsString());
assertEquals(target.world.getUID().toString(), payload.getAsJsonObject("target_location").get("world_id").getAsString());
f.dispatcher.accept(click);
assertEquals(1, revivals().size(), "Repeated selection cannot create another revival");
}
assertEquals(1, revivals().stream().filter(event -> event.getAsJsonObject("data")
.get("character_id").getAsString().equals(character.toString())).count());
}
@Test void registeredTimeoutReadAndAcknowledgedUpdateReachExportedJsonlOnlyAfterSave() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "admin-revive-selection-timeout-seconds"});
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "admin-revive-selection-timeout-seconds", "90"});
assertTrue(sender.messages.getLast().contains("=60"), "Queued save cannot claim an update");
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
assertTrue(sender.messages.getLast().contains("=90"));
for (int i = 0; i < 200 && ownConfig().size() < 2; i++) Thread.sleep(10);
var config = ownConfig();
assertEquals(1, config.stream().filter(event -> event.get("type").getAsString().endsWith("config.read")).count());
var changed = config.stream().filter(event -> event.get("type").getAsString().endsWith("config.changed")).toList();
assertEquals(1, changed.size());
assertEquals("60", changed.getFirst().getAsJsonObject("data").get("before").getAsString());
assertEquals("90", changed.getFirst().getAsJsonObject("data").get("after").getAsString());
assertEquals("later_selections", changed.getFirst().getAsJsonObject("data").get("applies_to").getAsString());
}
}
@Test void queuedAcknowledgedTimeoutChangesDescribeActualEffectiveTransitions() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{
"config", "admin-revive-selection-timeout-seconds", "90"});
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{
"config", "admin-revive-selection-timeout-seconds", "120"});
for (int i = 0; i < 20 && sender.messages.size() < 2; i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS);
assertNotNull(callback, "Both acknowledged configuration responses need a native callback");
callback.run();
}
assertEquals(2, sender.messages.size(), sender.messages.toString());
assertTrue(sender.messages.stream().allMatch(message -> message.startsWith("Saved ")),
sender.messages.toString());
for (int i = 0; i < 200 && ownConfig().stream().filter(event -> event.get("type")
.getAsString().endsWith("config.changed")).count() < 2; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var changed = ownConfig().stream().filter(event -> event.get("type").getAsString()
.endsWith("config.changed")).map(event -> event.getAsJsonObject("data")).toList();
assertEquals(2, changed.size());
assertEquals(List.of("60", "90"), changed.stream().map(data -> data.get("before").getAsString()).toList());
assertEquals(List.of("90", "120"), changed.stream().map(data -> data.get("after").getAsString()).toList());
assertEquals(List.of("90", "120"), changed.stream().map(data -> data.get("effective").getAsString()).toList());
}
@Test void failedAuthoritativeRestorationRetainsProofButExportsNoRevival() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
doReturn("Administrator").when(admin.player).getName();
doReturn("Target").when(target.player).getName();
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
f.players.put(admin.core.id, admin.player); f.players.put(target.core.id, target.player);
var dead = AdminRevivalNativeTest.dead(data, Instant.now().minusSeconds(172800)
.truncatedTo(ChronoUnit.SECONDS));
target.core.inventory.setItem(10, new games.dmg.fantasystick.items.TailBooks().create(dead));
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"revive", "Target"});
PartyBookNativeTest.drain(f);
var popup = admin.inventories.getFirst();
var corrupted = new java.util.concurrent.atomic.AtomicBoolean();
target.core.mutation = () -> {
if (!corrupted.compareAndSet(false, true)) return;
try {
var path = data.resolve("characters").resolve(dead.id() + ".yml");
Files.delete(path); Files.createDirectory(path);
} catch (Exception error) { throw new RuntimeException(error); }
};
var view = mock(org.bukkit.inventory.InventoryView.class);
when(view.getTopInventory()).thenReturn(popup);
when(view.getBottomInventory()).thenReturn(admin.core.inventory);
when(view.getPlayer()).thenReturn(admin.player);
f.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,
InventoryType.SlotType.CONTAINER, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL));
PartyBookNativeTest.drain(f);
var reservations = (games.dmg.fantasystick.persistence.RevivalStore)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "revivals");
reservations.find(dead.id()).join();
for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(f);
assertTrue(reservations.find(dead.id()).join().isPresent());
assertFalse(admin.messages.stream().anyMatch(message -> message.startsWith("Administratively restored")));
target.core.mutation = () -> {};
f.plugin.setEnabled(false);
}
assertTrue(revivals().isEmpty(), "A provisional sheet is not authoritative revival");
}
@Test void failedAuditIntentStoreCannotBlockAcknowledgedAdminTailRestoration() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
Files.writeString(data.resolve("admin-revival-event-intents"), "unavailable");
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
doReturn("Administrator").when(admin.player).getName();
doReturn("Target").when(target.player).getName();
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
f.players.put(admin.core.id, admin.player); f.players.put(target.core.id, target.player);
var dead = AdminRevivalNativeTest.dead(data, Instant.now().minusSeconds(172800)
.truncatedTo(ChronoUnit.SECONDS));
target.core.inventory.setItem(10, new games.dmg.fantasystick.items.TailBooks().create(dead));
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"revive", "Target"});
PartyBookNativeTest.drain(f);
var popup = admin.inventories.getFirst();
var view = mock(org.bukkit.inventory.InventoryView.class);
when(view.getTopInventory()).thenReturn(popup);
when(view.getBottomInventory()).thenReturn(admin.core.inventory);
when(view.getPlayer()).thenReturn(admin.player);
f.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,
InventoryType.SlotType.CONTAINER, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL));
PartyBookNativeTest.drain(f);
((games.dmg.fantasystick.persistence.RevivalStore)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "revivals")).find(dead.id()).join();
for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(f);
var record = ((games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"))
.find(dead.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertFalse(record.dead());
assertEquals(dead.id(), new games.dmg.fantasystick.items.CharacterSheets()
.identify(target.core.inventory.getItem(10)).orElseThrow());
assertTrue(admin.messages.stream().anyMatch(message -> message.contains("Administratively restored")));
f.plugin.setEnabled(false);
}
assertTrue(revivals().isEmpty(), "An unavailable store cannot assert export or undo gameplay");
}
@Test void confirmedInterruptedAdminReservationReconcilesWithStableIdentityAfterRestart() throws Exception {
var target = new FantasyStickLifecycleNativeTest.Actor(data);
doReturn("Target").when(target.player).getName();
var dead = AdminRevivalNativeTest.dead(data, Instant.now().minusSeconds(172800)
.truncatedTo(ChronoUnit.SECONDS));
var provisional = new games.dmg.fantasystick.persistence.CharacterRecord(dead.id(), dead.name(),
dead.revision(), dead.stats(), dead.level(), dead.experience(), dead.unspentPoints(),
dead.maximumHealth(), dead.learned(), dead.loadout(), dead.cooldowns(), dead.restrictedKills(),
false, dead.deathId(), dead.diedAt());
target.core.inventory.setItem(10, new games.dmg.fantasystick.items.CharacterSheets().create(provisional));
var adminId = UUID.randomUUID();
var operation = UUID.randomUUID();
var reservation = new games.dmg.fantasystick.persistence.RevivalStore.Reservation(operation, adminId,
target.core.id, dead.id(), dead.deathId(), Instant.now().truncatedTo(ChronoUnit.SECONDS),
10, 1, -1, true);
try (var store = new games.dmg.fantasystick.persistence.RevivalStore(data)) {
assertTrue(store.reserve(reservation).join().isPresent());
}
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.players.put(target.core.id, target.player); f.plugin.setEnabled(true);
var store = (games.dmg.fantasystick.persistence.RevivalStore)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "revivals");
store.target(target.core.id).join();
for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(f);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertFalse(storage.find(dead.id()).get(5, TimeUnit.SECONDS).orElseThrow().dead());
for (int i = 0; i < 200 && revivals().isEmpty(); i++) Thread.sleep(10);
var entries = revivals(); assertEquals(1, entries.size());
var event = entries.getFirst(); var values = event.getAsJsonObject("data");
assertEquals(operation.toString(), values.get("revival_operation_id").getAsString());
assertEquals(adminId.toString(), values.get("admin_player_id").getAsString());
assertEquals("system", values.get("actor_kind").getAsString());
assertTrue(values.get("recovery_reconciliation").getAsBoolean());
assertEquals("unavailable", values.get("actor_location_status").getAsString());
assertEquals(dead.id().toString(), values.get("character_id").getAsString());
}
try (var restarted = new FantasyStickLifecycleNativeTest.Fixture(data)) {
restarted.players.put(target.core.id, target.player); restarted.plugin.setEnabled(true);
for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(restarted);
}
assertEquals(1, revivals().size(), "Reconciliation and a later restart cannot export duplicate success");
}
@Test void deniedInvalidAndFailedTimeoutUpdatesCannotProduceConfigChanged() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var sender = new EquipmentAdminCommandNativeTest.Sender();
sender.allowed = false;
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "admin-revive-selection-timeout-seconds", "90"});
sender.allowed = true;
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "admin-revive-selection-timeout-seconds", "0"});
Files.createDirectory(data.resolve("config.yml"));
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "admin-revive-selection-timeout-seconds", "90"});
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
assertTrue(sender.messages.getLast().contains("save failed"));
f.plugin.setEnabled(false);
}
assertTrue(ownConfig().stream().noneMatch(event -> event.get("type").getAsString().endsWith("config.changed")));
}
@Test void cancelledOrStaleTailSelectionCannotExportRevival() throws Exception {
for (var route : List.of("cancel", "movement", "permission")) {
var dir = data.resolve(route); Files.createDirectories(dir);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(dir)) {
f.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(dir);
var target = new FantasyStickLifecycleNativeTest.Actor(dir);
doReturn("Administrator").when(admin.player).getName();
doReturn("Target").when(target.player).getName();
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
f.players.put(admin.core.id, admin.player); f.players.put(target.core.id, target.player);
var dead = AdminRevivalNativeTest.dead(dir, Instant.now().minusSeconds(172800)
.truncatedTo(ChronoUnit.SECONDS));
var tail = new games.dmg.fantasystick.items.TailBooks().create(dead);
target.core.inventory.setItem(10, tail);
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"revive", "Target"});
((games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"))
.find(dead.id()).get(5, TimeUnit.SECONDS);
f.tick();
var popup = admin.inventories.getFirst();
if (!route.equals("cancel")) {
if (route.equals("movement")) target.core.inventory.setItem(10,
new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND));
if (route.equals("permission")) doReturn(false).when(admin.player).hasPermission("fantasystick.admin");
var view = mock(org.bukkit.inventory.InventoryView.class);
when(view.getTopInventory()).thenReturn(popup);
when(view.getBottomInventory()).thenReturn(admin.core.inventory);
when(view.getPlayer()).thenReturn(admin.player);
f.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,
InventoryType.SlotType.CONTAINER, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL));
PartyBookNativeTest.drain(f);
}
f.plugin.setEnabled(false);
}
assertTrue(events(dir).stream().noneMatch(event -> event.get("type").getAsString()
.endsWith("character.revived")), route + " did not restore an acknowledged character");
}
}
private List<JsonObject> revivals() throws Exception {
return events().stream().filter(event -> event.get("type").getAsString().endsWith("character.revived"))
.filter(event -> event.getAsJsonObject("data").has("revival_kind")
&& event.getAsJsonObject("data").get("revival_kind").getAsString().equals("admin_tail")).toList();
}
private List<JsonObject> ownConfig() throws Exception {
return events().stream().filter(event -> event.get("type").getAsString().endsWith("config.read")
|| event.get("type").getAsString().endsWith("config.changed"))
.filter(event -> event.getAsJsonObject("data").has("key")
&& event.getAsJsonObject("data").get("key").getAsString()
.equals("admin-revive-selection-timeout-seconds")).toList();
}
private List<JsonObject> events() throws Exception { return events(data); }
private List<JsonObject> events(Path root) throws Exception {
var result = new ArrayList<JsonObject>();
if (!Files.isDirectory(root.resolve("events"))) return result;
try (var days = Files.list(root.resolve("events"))) {
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}"))
.toList()) {
try (var segments = Files.list(day)) {
for (var file : segments.filter(path -> path.getFileName().toString().startsWith("events-")
&& path.getFileName().toString().endsWith(".jsonl")).toList()) {
for (var line : Files.readAllLines(file)) {
if (!line.isBlank()) result.add(JsonParser.parseString(line).getAsJsonObject());
}
}
}
}
}
return result;
}
}
@@ -326,14 +326,17 @@ class BasicAttackEventsNativeTest {
games.dmg.fantasystick.session.EquipmentSessions sessions,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
var transitioning = (java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(sessions, "transitioning");
for (int i = 0; i < 80; i++) {
var deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15);
while (System.nanoTime() < deadline) {
if (sessions.active(actor.core.id).isEmpty() && !transitioning.containsKey(actor.core.id)
&& restoredExitProof(fixture, actor)) return;
var task = fixture.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(task, "No exit callback before acknowledged receipt and native RESTORED marker");
task.run();
// The receipt worker can advance independently of queued main-thread callbacks.
var task = fixture.tasks.poll(100, java.util.concurrent.TimeUnit.MILLISECONDS);
if (task != null) task.run();
}
fail("Session exit did not clear owned native marker and acknowledged receipt before restart");
fail("Session exit did not clear owned native marker and acknowledged receipt before restart; "
+ "active=" + sessions.active(actor.core.id) + ", transitioning=" + transitioning.containsKey(actor.core.id)
+ ", marker=" + actor.core.marker() + ", receipt_proof=" + restoredExitProof(fixture, actor));
}
private static void awaitOrdinaryArrival(FantasyStickLifecycleNativeTest.Fixture fixture,
@@ -1615,6 +1615,7 @@ class FantasyStickLifecycleNativeTest {
final List<org.bukkit.Sound> sounds = new ArrayList<>();
final List<net.kyori.adventure.title.Title> titles = new ArrayList<>();
final List<Object[]> resourcePacks = new ArrayList<>();
final List<java.util.UUID> removedResourcePacks = new ArrayList<>();
final java.util.Set<org.bukkit.NamespacedKey> knownRecipes = new java.util.LinkedHashSet<>();
final List<List<org.bukkit.NamespacedKey>> recipeAwards = new ArrayList<>();
final net.minecraft.world.entity.ai.attributes.AttributeMap healthAttributes = new net.minecraft.world.entity.ai.attributes.AttributeMap(
@@ -1720,6 +1721,7 @@ class FantasyStickLifecycleNativeTest {
case "playSound" -> { if (args.length > 1 && args[1] instanceof org.bukkit.Sound sound) sounds.add(sound); yield null; }
case "showTitle" -> { titles.add((net.kyori.adventure.title.Title)args[0]); yield null; }
case "addResourcePack" -> { resourcePacks.add(args.clone()); yield null; }
case "removeResourcePack" -> { removedResourcePacks.add((java.util.UUID)args[0]); yield null; }
case "hasDiscoveredRecipe" -> knownRecipes.contains((org.bukkit.NamespacedKey) args[0]);
case "discoverRecipes" -> {
var requested = ((java.util.Collection<?>) args[0]).stream()
@@ -95,7 +95,7 @@ final class FishingQuestNativeTest {
var firstCatch=new PlayerFishEvent(actor.player,caught,first,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);firstCatch.setExpToDrop(6);f.dispatcher.accept(firstCatch);
assertTrue(firstCatch.isCancelled());assertEquals(0,firstCatch.getExpToDrop());verify(caught).remove();verify(first).remove();
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");await(f,()->storage.find(actor.core.record.id()).join().orElseThrow().experience().equals(BigInteger.TWO));
assertEquals(BigInteger.ZERO,fishing.quantity(actor.core.record.id(),site.fishing().itemId()));assertTrue(actor.messages.stream().anyMatch(message->message.contains("no quest item")));
assertEquals(BigInteger.ZERO,fishing.quantity(actor.core.record.id(),site.fishing().itemId()));await(f,()->actor.messages.stream().anyMatch(message->message.contains("no quest item")));assertTrue(actor.messages.stream().anyMatch(message->message.contains("no quest item")));
var next=mock(FishHook.class);when(next.getUniqueId()).thenReturn(UUID.randomUUID());when(next.getLocation()).thenReturn(actor.core.location.clone());
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,next,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));
f.dispatcher.accept(new PlayerFishEvent(actor.player,caught,first,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH));
@@ -2,24 +2,223 @@ package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;import static org.mockito.Mockito.*;
import games.dmg.fantasystick.persistence.*;import games.dmg.fantasystick.progression.Stat;import java.math.BigInteger;import java.nio.file.Path;import java.util.*;import net.minecraft.network.chat.ClickEvent;import org.bukkit.event.player.PlayerInteractEvent;import org.bukkit.inventory.EquipmentSlot;import org.junit.jupiter.api.*;import org.junit.jupiter.api.io.TempDir;
class InspireNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void registeredBookSelfCastExpiryReachesExportedJsonlWithoutInventingPrevention()throws Exception{
UUID playerId,characterId,sessionId;
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,actor,false);
f.players.put(actor.core.id,actor.player);f.activate(actor);playerId=actor.core.id;characterId=actor.core.record.id();
var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");
sessionId=sessions.active(playerId).orElseThrow().session();
learnAndAssign(f,actor);var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);
doAnswer(call->absorption.get()).when(actor.player).getAbsorptionAmount();
doAnswer(call->{absorption.set(call.getArgument(0));return null;}).when(actor.player).setAbsorptionAmount(anyDouble());
doReturn(true).when(actor.player).isSneaking();use(f,actor);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
assertEquals(7,absorption.get(),1e-9);f.delayed.getLast().run();assertEquals(2,absorption.get(),1e-9);
}
var own=inspireEvents(data);assertEquals(1,count(own,"ability.learned"));assertEquals(1,count(own,"ability.assigned"));
assertEquals(1,count(own,"ability.cast"));assertEquals(1,count(own,"effect.applied"));
assertEquals(1,count(own,"effect.ended"));assertEquals(0,count(own,"combat.prevented"));
var cast=record(own,"ability.cast");var applied=record(own,"effect.applied");var ended=record(own,"effect.ended");
assertEquals(playerId.toString(),cast.get("player_id").getAsString());
assertEquals(characterId.toString(),cast.get("character_id").getAsString());
assertEquals(sessionId.toString(),cast.get("session_id").getAsString());
assertTrue(cast.has("cooldown_until"));assertEquals("5",applied.get("granted_amount").getAsString());
assertEquals(applied.get("application_id").getAsString(),ended.get("application_id").getAsString());
assertEquals("5",ended.get("remaining_amount").getAsString(),"unused native protection expires without prevention");
assertEquals("expired",ended.get("reason").getAsString());
assertEquals("expired",record(own,"ability.resolved").get("outcome").getAsString());
}
@Test void registeredAllyAbsorptionRecordsOnlyObservedConsumedHealth()throws Exception{
UUID actorId,characterId,sessionId,targetId,targetCharacterId,targetSessionId,worldId;
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);
withInspire(data,a,true);f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(b);
var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");
actorId=a.core.id;characterId=a.core.record.id();sessionId=sessions.active(actorId).orElseThrow().session();
targetId=b.core.id;targetCharacterId=b.core.record.id();targetSessionId=sessions.active(targetId).orElseThrow().session();
worldId=UUID.randomUUID();when(a.world.getUID()).thenReturn(worldId);
doReturn(false).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();
doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();
doReturn(true).when(a.player).hasLineOfSight(b.player);doReturn(b.player).when(a.player).getTargetEntity(64);
var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);
doAnswer(call->absorption.get()).when(b.player).getAbsorptionAmount();
doAnswer(call->{absorption.set(call.getArgument(0));return null;}).when(b.player).setAbsorptionAmount(anyDouble());
assertTrue(f.feedbackCommand.execute(a.player,"fantasyfeedback",new String[]{"off"}));f.tick();
use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(5,absorption.get(),1e-9);
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(enemy.getLocation()).thenReturn(new org.bukkit.Location(a.world,8,64,1));
var source=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK)
.withCausingEntity(enemy).withDirectEntity(enemy).build();
var cancelled=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,b.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3);cancelled.setCancelled(true);
f.dispatcher.accept(cancelled);PartyBookNativeTest.drain(f);assertEquals(5,absorption.get(),1e-9);
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,b.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3);
f.dispatcher.accept(hit);absorption.set(2.0);PartyBookNativeTest.drain(f);
assertEquals(2,absorption.get(),1e-9);
}
var own=inspireEvents(data);assertEquals(1,count(own,"ability.cast"));assertEquals(1,count(own,"effect.applied"));
assertEquals(1,count(own,"combat.prevented"));var consumed=record(own,"combat.prevented");
assertEquals(actorId.toString(),consumed.get("player_id").getAsString());
assertEquals(characterId.toString(),consumed.get("character_id").getAsString());
assertEquals(sessionId.toString(),consumed.get("session_id").getAsString());
assertEquals(targetId.toString(),consumed.get("protected_target_id").getAsString());
assertEquals(targetCharacterId.toString(),consumed.get("protected_character_id").getAsString());
assertEquals(targetSessionId.toString(),consumed.get("protected_session_id").getAsString());
assertEquals(worldId.toString(),consumed.getAsJsonObject("protected_location").get("world_id").getAsString());
assertEquals(8,consumed.getAsJsonObject("protected_location").get("x").getAsDouble(),1e-9);
assertEquals(3,consumed.get("actual_amount").getAsDouble(),1e-9);
assertEquals(record(own,"ability.cast").get("action_id").getAsString(),consumed.get("action_id").getAsString());
assertEquals(record(own,"effect.applied").get("application_id").getAsString(),consumed.get("effect_application_id").getAsString());
}
@Test void unavailableAuditOnlyAttackerLocationCannotInterruptNativeAbsorption()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);
f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);
doAnswer(call->absorption.get()).when(a.player).getAbsorptionAmount();
doAnswer(call->{absorption.set(call.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());
doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
var enemy=mock(org.bukkit.entity.Zombie.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
when(enemy.getLocation()).thenThrow(new IllegalStateException("unavailable audit location"));
var source=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK)
.withCausingEntity(enemy).withDirectEntity(enemy).build();
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,a.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3);
var owner=(games.dmg.fantasystick.ability.TemporaryHealth)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"temporaryHealth");
owner.damage(hit);absorption.set(2.0);PartyBookNativeTest.drain(f);assertEquals(2,absorption.get(),1e-9);
}
var own=inspireEvents(data);assertEquals(1,count(own,"combat.prevented"));
assertFalse(record(own,"combat.prevented").has("attacker_location"));
}
@Test void rejectedNativeAbsorptionSetterCannotLeaveAnOrphanOwnedGrant()throws Exception{
var attempts=new java.util.concurrent.atomic.AtomicInteger();
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);
f.players.put(a.core.id,a.player);f.activate(a);
doReturn(0.0).when(a.player).getAbsorptionAmount();
doAnswer(call->{if(attempts.incrementAndGet()==1)throw new IllegalStateException("native refused");
return null;}).when(a.player).setAbsorptionAmount(anyDouble());
doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
assertEquals(1,attempts.get(),"native admission was attempted once");
}
assertEquals(1,attempts.get(),"an unadmitted effect must not be cleaned up as an owned native grant");
var own=inspireEvents(data);assertEquals(1,count(own,"ability.cast"));
assertEquals(0,count(own,"effect.applied"));assertEquals(0,count(own,"combat.prevented"));
assertEquals("failed",record(own,"ability.resolved").get("outcome").getAsString());
}
@Test void duplicateNativeCallbackCannotDoubleClaimOneAbsorptionDrop()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);
f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);
doAnswer(call->absorption.get()).when(a.player).getAbsorptionAmount();
doAnswer(call->{absorption.set(call.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());
doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
assertEquals(5,absorption.get(),1e-9);
var enemy=mock(org.bukkit.entity.Zombie.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
var source=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK)
.withCausingEntity(enemy).withDirectEntity(enemy).build();
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,a.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3);
f.dispatcher.accept(hit);f.dispatcher.accept(hit);absorption.set(2.0);PartyBookNativeTest.drain(f);
assertEquals(2,absorption.get(),1e-9);
}
var own=inspireEvents(data);assertEquals(1,count(own,"combat.prevented"));
assertEquals(3,record(own,"combat.prevented").get("actual_amount").getAsDouble(),1e-9);
}
@Test void rejectedWriterCannotChangeNativeAbsorptionOrInventPrevention()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);
f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);
doAnswer(call->absorption.get()).when(a.player).getAbsorptionAmount();
doAnswer(call->{absorption.set(call.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());
doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
((games.dmg.fantasystick.events.LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog")).close();
var enemy=mock(org.bukkit.entity.Zombie.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
var source=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK)
.withCausingEntity(enemy).withDirectEntity(enemy).build();
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,a.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3);
f.dispatcher.accept(hit);absorption.set(2.0);PartyBookNativeTest.drain(f);
assertEquals(2,absorption.get(),1e-9);f.delayed.getLast().run();assertEquals(0,absorption.get(),1e-9);
}
assertEquals(0,count(inspireEvents(data),"combat.prevented"));
}
@Test void acknowledgedInspireConfigurationReadAndChangeReachExportedJsonl()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var admin=new EquipmentAdminCommandNativeTest.Sender();admin.allowed=true;
f.command.execute(admin.port,"fstickadmin",new String[]{"config","inspire-base-temporary-health"});
assertTrue(admin.messages.getLast().contains("inspire-base-temporary-health=4"));
f.command.execute(admin.port,"fstickadmin",new String[]{"config","inspire-base-temporary-health","9"});
var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();
var registry=(games.dmg.fantasystick.ability.AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");
assertEquals(new java.math.BigDecimal("9"),registry.find("inspire").orElseThrow().parameters().get("base-temporary-health"));
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(admin.port,"fstickadmin",new String[]{"config","inspire-base-temporary-health","10"});
var failed=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(failed);failed.run();
}
admin.allowed=false;f.command.execute(admin.port,"fstickadmin",new String[]{"config","inspire-base-temporary-health"});
}
var own=inspireEvents(data);assertEquals(1,count(own,"config.read"));assertEquals(1,count(own,"config.changed"));
var changed=record(own,"config.changed");assertEquals("4",changed.get("before").getAsString());
assertEquals("9",changed.get("after").getAsString());
}
@Test void bookAcquisitionSneakUseAddsOwnedTemporaryHealthAndExpiryPreservesUnrelatedAbsorption()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,false);f.players.put(a.core.id,a.player);f.activate(a);a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);String book=PartyBookNativeTest.text(a).replaceAll("\\s+","");assertTrue(book.contains("Inspire"));assertTrue(book.contains("CHR12"));assertTrue(book.contains("WIS10"));assertFalse(book.contains("Cost:"));assertTrue(book.contains("12"));assertTrue(book.contains("6s"));PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
double maximum=a.maximumHealth.getBaseValue();var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(c->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);assertEquals(maximum,a.maximumHealth.getBaseValue(),1e-9);assertEquals(120L,f.delayedTicks.getLast());f.delayed.getLast().run();assertEquals(2,absorption.get(),1e-9);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire"));}}
@Test void aimedPartyAllyActualAbsorptionCreatesPersistedSupportShare()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(b);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(a.core.id).orElseThrow();assertTrue(parties.accept(b.core.id,parties.invite(a.core.id,party.id(),b.core.id).orElseThrow().id()));
doReturn(false).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();doReturn(true).when(a.player).hasLineOfSight(b.player);doReturn(b.player).when(a.player).getTargetEntity(64);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(c->absorption.get()).when(b.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(b.player).setAbsorptionAmount(anyDouble());use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(5,absorption.get(),1e-9);
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());when(enemy.getLocation()).thenReturn(new org.bukkit.Location(a.world,8,64,0));var enemySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,b.player,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,3));absorption.set(2.0);PartyBookNativeTest.drain(f);String feedback="Inspire prevented 3 damage to "+b.core.record.name()+".";assertEquals(1,a.messages.stream().filter(feedback::equals).count());assertEquals(1,b.messages.stream().filter(feedback::equals).count());var sourceSheet=a.core.inventory.getItemInOffHand().clone();a.core.inventory.setItemInOffHand(null);f.changed(a);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(a);var holder=new FantasyStickLifecycleNativeTest.Actor(data);holder.core.inventory.setItemInOffHand(sourceSheet);f.players.put(holder.core.id,holder.player);f.activate(holder);
var allySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(b.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,allySource,3));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy,allySource,new java.util.ArrayList<>(),0));for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(BigInteger.valueOf(5),storage.find(a.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage.find(b.core.record.id()).get().orElseThrow().experience());}}
@Test void normalSelfOrdinaryOutOfRangeAndObstructedTargetsRefuseBeforeCooldown()throws Exception{for(int route=0;route<4;route++){var dir=data.resolve("refuse-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);withInspire(dir,a,true);f.players.put(a.core.id,a.player);f.activate(a);org.bukkit.entity.Player target;if(route==0)target=a.player;else if(route==1){target=mock(org.bukkit.entity.Player.class);when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());}else{var b=new FantasyStickLifecycleNativeTest.Actor(dir);target=b.player;f.players.put(b.core.id,b.player);f.activate(b);}doReturn(false).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,route==2?13:8,64,0)).when(target).getLocation();doReturn(route!=3).when(a.player).hasLineOfSight(target);doReturn(target).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("inspire"));}}}
@Test void normalSelfOrdinaryOutOfRangeAndObstructedTargetsRefuseBeforeCooldown()throws Exception{for(int route=0;route<4;route++){var dir=data.resolve("refuse-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);withInspire(dir,a,true);f.players.put(a.core.id,a.player);f.activate(a);org.bukkit.entity.Player target;if(route==0)target=a.player;else if(route==1){target=mock(org.bukkit.entity.Player.class);when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());}else{var b=new FantasyStickLifecycleNativeTest.Actor(dir);target=b.player;f.players.put(b.core.id,b.player);f.activate(b);}doReturn(false).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,route==2?13:8,64,0)).when(target).getLocation();doReturn(route!=3).when(a.player).hasLineOfSight(target);doReturn(target).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("inspire"));}
var own=inspireEvents(dir);assertEquals(0,count(own,"ability.cast"));assertEquals(0,count(own,"effect.applied"));
assertEquals("refused",record(own,"ability.resolved").get("outcome").getAsString());
}}
@Test void nonPartyTargetIsAllowedAndStrongestRemainingCopiesPreserveUnrelatedAbsorption()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"inspire-cooldown-seconds: 0\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var c=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);withInspire(data,c,true);f.players.put(a.core.id,a.player);f.players.put(c.core.id,c.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(c);f.activate(b);var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(x->absorption.get()).when(b.player).getAbsorptionAmount();doAnswer(x->{absorption.set(x.getArgument(0));return null;}).when(b.player).setAbsorptionAmount(anyDouble());for(var caster:List.of(a,c)){doReturn(false).when(caster.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(caster.player).getLocation();doReturn(true).when(caster.player).hasLineOfSight(b.player);doReturn(b.player).when(caster.player).getTargetEntity(64);}doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","6"});var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();use(f,c);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(9,absorption.get(),1e-9);
var enemy=mock(org.bukkit.entity.Zombie.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());var source=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,b.player,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));absorption.set(6.0);PartyBookNativeTest.drain(f);f.delayed.getLast().run();assertEquals(4,absorption.get(),1e-9);f.delayed.getFirst().run();assertEquals(2,absorption.get(),1e-9);}}
var enemy=mock(org.bukkit.entity.Zombie.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());var source=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,b.player,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));absorption.set(6.0);PartyBookNativeTest.drain(f);f.delayed.getLast().run();assertEquals(4,absorption.get(),1e-9);f.delayed.getFirst().run();assertEquals(2,absorption.get(),1e-9);}
var own=inspireEvents(data);assertEquals(2,count(own,"ability.cast"));assertEquals(2,count(own,"effect.applied"));
assertEquals(1,count(own,"combat.prevented"));assertEquals(2,count(own,"effect.ended"));
var applied=own.stream().filter(event->event.get("type").getAsString().endsWith("effect.applied")).toList();
var prevented=record(own,"combat.prevented");
assertEquals(applied.getLast().getAsJsonObject("data").get("application_id").getAsString(),
prevented.get("effect_application_id").getAsString(),"strongest accepted grant owns observed absorption");
assertEquals("7",applied.getLast().getAsJsonObject("data").get("granted_amount").getAsString());
}
@Test void cancelledOrSelfOnlyProtectionCannotManufactureSupportShare()throws Exception{for(int route=0;route<2;route++){var dir=data.resolve("no-support-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);var b=new FantasyStickLifecycleNativeTest.Actor(dir);withInspire(dir,a,true);f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(b);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(a.core.id).orElseThrow();assertTrue(parties.accept(b.core.id,parties.invite(a.core.id,party.id(),b.core.id).orElseThrow().id()));var target=route==0?b.player:a.player;var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(x->absorption.get()).when(target).getAbsorptionAmount();doAnswer(x->{absorption.set(x.getArgument(0));return null;}).when(target).setAbsorptionAmount(anyDouble());doReturn(route==1).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();doReturn(true).when(a.player).hasLineOfSight(b.player);doReturn(b.player).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(enemy.getLocation()).thenReturn(new org.bukkit.Location(a.world,8,64,0));when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());var enemySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,target,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,3);if(route==0)hit.setCancelled(true);f.dispatcher.accept(hit);if(route==1){absorption.set(2.0);PartyBookNativeTest.drain(f);}var allySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(b.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,allySource,3));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy,allySource,new java.util.ArrayList<>(),0));for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(BigInteger.valueOf(5),storage.find(a.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage.find(b.core.record.id()).get().orElseThrow().experience());}}}
@Test void exactFormulaSupportsFiniteUncappedCharisma(){assertEquals(BigInteger.valueOf(5),games.dmg.fantasystick.ability.Inspire.amount(BigInteger.valueOf(12)));assertEquals(BigInteger.valueOf(3),games.dmg.fantasystick.ability.Inspire.amount(BigInteger.valueOf(9)));var huge=BigInteger.TEN.pow(500);assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(4)),games.dmg.fantasystick.ability.Inspire.amount(huge));}
@Test void failedCooldownPersistencePublishesNoTemporaryHealth(){var storage=mock(CharacterStorage.class);var sessions=mock(games.dmg.fantasystick.session.EquipmentSessions.class);var parties=new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(),ignored->true);var temporary=new games.dmg.fantasystick.ability.TemporaryHealth(sessions,parties,Runnable::run,ignored->{});var inspire=new games.dmg.fantasystick.ability.Inspire(games.dmg.fantasystick.ability.Inspire.Settings.defaults(),temporary);var registry=new games.dmg.fantasystick.ability.AbilityRegistry();registry.register(inspire.definition());var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());var stats=new EnumMap<>(base.stats());stats.put(Stat.CHR,BigInteger.valueOf(12));stats.put(Stat.WIS,BigInteger.TEN);var record=new CharacterRecord(base.id(),base.name(),base.revision(),stats,base.level(),base.experience(),base.unspentPoints(),base.health(),Set.of("inspire"),List.of("inspire","","","","","",""),base.cooldowns(),base.restrictedKills(),false,null,null);var active=new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),java.util.UUID.randomUUID(),record.id());var player=mock(org.bukkit.entity.Player.class);when(player.getUniqueId()).thenReturn(active.player());when(player.isSneaking()).thenReturn(true);when(sessions.active(active.player())).thenReturn(java.util.Optional.of(active));when(sessions.current(active)).thenReturn(true);when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));var failed=new java.util.concurrent.CompletableFuture<CharacterStorage.AbilityMutation>();failed.completeExceptionally(new java.io.IOException("simulated"));when(storage.commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("inspire"),any())).thenReturn(failed);var engine=new games.dmg.fantasystick.ability.AbilityEngine(registry,storage,sessions,parties,java.time.Clock.systemUTC(),Runnable::run,(delay,action)->()->{},ignored->{},ignored->{});engine.accept(new games.dmg.fantasystick.session.ControlInput(player,active,2,"inspire",games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,games.dmg.fantasystick.session.ControlInput.Gesture.USE,player,0,0,()->true));verify(storage).commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("inspire"),any());verify(player,never()).setAbsorptionAmount(anyDouble());engine.close();inspire.close();temporary.close();parties.close();}
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOnlyOwnedTemporaryHealth()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"inspire-cooldown-seconds: 60\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(x->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(x->{absorption.set(x.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var deadline=storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire");a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);var unlearn=button(a,"[Unlearn]","inspire");PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(2,absorption.get(),1e-9);var refunded=storage.find(a.core.record.id()).get().orElseThrow();assertEquals(BigInteger.valueOf(40),refunded.unspentPoints());assertEquals(deadline,refunded.cooldowns().get("inspire"));learnAndAssign(f,a);use(f,a);assertTrue(a.messages.stream().anyMatch(m->m.contains("ready in")));var sheet=a.core.inventory.getItemInOffHand().clone();a.core.inventory.setItemInOffHand(null);f.changed(a);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(a);var b=new FantasyStickLifecycleNativeTest.Actor(data);b.core.inventory.setItemInOffHand(sheet);f.players.put(b.core.id,b.player);f.activate(b);doReturn(true).when(b.player).isSneaking();use(f,b);assertTrue(b.messages.stream().anyMatch(m->m.contains("ready in")));assertEquals(deadline,storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire"));}}
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOnlyOwnedTemporaryHealth()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"inspire-cooldown-seconds: 60\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(x->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(x->{absorption.set(x.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var deadline=storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire");a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);var unlearn=button(a,"[Unlearn]","inspire");PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(2,absorption.get(),1e-9);var refunded=storage.find(a.core.record.id()).get().orElseThrow();assertEquals(BigInteger.valueOf(40),refunded.unspentPoints());assertEquals(deadline,refunded.cooldowns().get("inspire"));learnAndAssign(f,a);use(f,a);assertTrue(a.messages.stream().anyMatch(m->m.contains("ready in")));var sheet=a.core.inventory.getItemInOffHand().clone();a.core.inventory.setItemInOffHand(null);f.changed(a);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(a);var b=new FantasyStickLifecycleNativeTest.Actor(data);b.core.inventory.setItemInOffHand(sheet);f.players.put(b.core.id,b.player);f.activate(b);doReturn(true).when(b.player).isSneaking();use(f,b);assertTrue(b.messages.stream().anyMatch(m->m.contains("ready in")));assertEquals(deadline,storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire"));}
var own=inspireEvents(data);assertEquals(1,count(own,"ability.cast"));
assertEquals(1,count(own,"effect.applied"));assertEquals(1,count(own,"effect.ended"));
assertEquals(0,count(own,"combat.prevented"));
assertEquals("interrupted",record(own,"effect.ended").get("reason").getAsString());
assertEquals("5",record(own,"effect.ended").get("remaining_amount").getAsString());
}
@Test void configuredDefinitionAdminRestartFutureCastAndStartupValidationCoverEverySetting()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"inspire-base-temporary-health: 8\ninspire-cooldown-seconds: 0\ninspire-range-blocks: 16\ninspire-duration-seconds: 8\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var registry=(games.dmg.fantasystick.ability.AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");var d=registry.find("inspire").orElseThrow();assertEquals(java.time.Duration.ZERO,d.cooldown());assertEquals(new java.math.BigDecimal("8"),d.parameters().get("base-temporary-health"));assertEquals(0,new java.math.BigDecimal("16").compareTo(d.parameters().get("range")));assertEquals(0,new java.math.BigDecimal("8").compareTo(d.parameters().get("duration-seconds")));
var sender=new EquipmentAdminCommandNativeTest.Sender();sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","inspire-"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","9"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;assertEquals(List.of("inspire-base-temporary-health","inspire-cooldown-seconds","inspire-duration-seconds","inspire-range-blocks"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","inspire-"}));assertEquals(List.of("4","8"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health",""}));f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health"});assertTrue(sender.messages.getLast().contains("=8"));f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","9"});var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals(new java.math.BigDecimal("9"),registry.find("inspire").orElseThrow().parameters().get("base-temporary-health"));
var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(c->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(10,absorption.get(),1e-9);assertEquals(160L,f.delayedTicks.getLast());
f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","-1"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","9","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reload=(games.dmg.fantasystick.ability.AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new java.math.BigDecimal("9"),reload.find("inspire").orElseThrow().parameters().get("base-temporary-health"));}
for(String invalid:List.of("inspire-base-temporary-health: -1\n","inspire-cooldown-seconds: -1\n","inspire-range-blocks: 0\n","inspire-duration-seconds: 0\n")){var dir=data.resolve(Integer.toHexString(invalid.hashCode()));java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),invalid);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty());}}}
private static List<com.google.gson.JsonObject> inspireEvents(Path root)throws Exception{
var results=new java.util.ArrayList<com.google.gson.JsonObject>();
try(var days=java.nio.file.Files.list(root.resolve("events"))){
for(var day:days.filter(path->path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")).toList())
try(var segments=java.nio.file.Files.list(day)){
for(var path:segments.filter(file->file.getFileName().toString().startsWith("events-")
&&file.getFileName().toString().endsWith(".jsonl")).toList())
for(var line:java.nio.file.Files.readAllLines(path))
if(!line.isBlank()){
var event=com.google.gson.JsonParser.parseString(line).getAsJsonObject();
var data=event.getAsJsonObject("data");
if(data!=null&&data.has("ability_id")&&data.get("ability_id").getAsString().equals("inspire"))results.add(event);
}
}
}
return results;
}
private static long count(List<com.google.gson.JsonObject> own,String suffix){return own.stream().filter(event->event.get("type").getAsString().endsWith(suffix)).count();}
private static com.google.gson.JsonObject record(List<com.google.gson.JsonObject> own,String suffix){return own.stream().filter(event->event.get("type").getAsString().endsWith(suffix)).findFirst().orElseThrow().getAsJsonObject("data");}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
private static void use(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a){a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND));}
private static ClickEvent.Custom button(FantasyStickLifecycleNativeTest.Actor a,String label,String ability){var b=org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast()).get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);assertNotNull(b);for(var p:b.pages()){var x=button(p.raw(),label,ability);if(x!=null)return x;}throw new AssertionError("missing action");}
@@ -118,6 +118,78 @@ class OutOfCombatRecoveryNativeTest {
@Test void eventSinkFailureCannotChangeAnAcceptedRecoveryPulse(){var f=new Direct();f.recovery.attachBonusHealing(ignored->BigInteger.valueOf(3));f.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(event->{throw new IllegalStateException("disk");},f.clock),ignored->BigInteger.valueOf(3));assertDoesNotThrow(()->{f.recovery.sweep();f.clock.advance(Duration.ofSeconds(10));f.recovery.sweep();f.runNext();});assertEquals(8,f.health.get());assertEquals(0,new BigDecimal("8").compareTo(f.record.get().health()));}
@Test void unloadedAuditWorldCannotInterruptAcceptedOrRefusedRecovery() {
var f = new Direct();
var captured = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
f.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(captured::add, f.clock), ignored -> BigInteger.ZERO);
var location = mock(org.bukkit.Location.class);
when(location.getWorld()).thenThrow(new IllegalArgumentException("World unloaded"));
when(f.player.getLocation()).thenReturn(location);
f.recovery.sweep(); f.clock.advance(Duration.ofSeconds(10));
assertDoesNotThrow(() -> { f.recovery.sweep(); f.runNext(); });
assertEquals(5, f.health.get());
assertEquals(0, BigDecimal.valueOf(5).compareTo(f.record.get().health()));
assertEquals(List.of("action.observed", "action.resolved", "combat.healed"),
captured.stream().map(games.dmg.fantasystick.events.EventInput::type).toList());
captured.forEach(input -> {
assertEquals(f.active.player(), input.context().playerId());
assertEquals(f.active.character(), input.context().characterId());
assertEquals(f.active.session(), input.context().sessionId());
assertNull(input.context().location());
assertEquals("unavailable", input.data().get("location_status"));
});
f.clock.advance(Duration.ofSeconds(9)); f.recovery.sweep(); assertEquals(1, f.events.size());
f.clock.advance(Duration.ofSeconds(1)); f.recovery.sweep(); f.runNext();
assertEquals(6, f.health.get(), "The next interval is not suppressed or replayed");
var cancelled = new Direct(); cancelled.veto = true;
var refusals = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
cancelled.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(refusals::add, cancelled.clock), ignored -> BigInteger.ZERO);
when(cancelled.player.getLocation()).thenReturn(location);
cancelled.recovery.sweep(); cancelled.clock.advance(Duration.ofSeconds(10)); cancelled.recovery.sweep();
assertEquals(4, cancelled.health.get());
assertEquals(List.of("action.observed", "action.resolved"),
refusals.stream().map(games.dmg.fantasystick.events.EventInput::type).toList());
assertEquals("refused", refusals.getLast().data().get("outcome"));
assertEquals("unavailable", refusals.getLast().data().get("location_status"));
}
@Test void availableAuditLocationRetainsItsExactWorldAndCoordinates() {
var f = new Direct();
var captured = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
f.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(captured::add, f.clock), ignored -> BigInteger.ZERO);
var world = mock(org.bukkit.World.class); var worldId = java.util.UUID.randomUUID();
when(world.getUID()).thenReturn(worldId); when(world.getName()).thenReturn("known-world");
var location = mock(org.bukkit.Location.class);
when(location.getWorld()).thenReturn(world);
when(location.getX()).thenReturn(2.5); when(location.getY()).thenReturn(65d); when(location.getZ()).thenReturn(-3d);
when(f.player.getLocation()).thenReturn(location);
f.recovery.sweep(); f.clock.advance(Duration.ofSeconds(10)); f.recovery.sweep(); f.runNext();
assertEquals(5, f.health.get());
assertEquals(worldId, captured.getFirst().context().location().worldId());
assertEquals("known-world", captured.getFirst().context().location().worldName());
assertEquals(2.5, captured.getFirst().context().location().x());
assertEquals(65d, captured.getFirst().context().location().y());
assertEquals(-3d, captured.getFirst().context().location().z());
assertFalse(captured.getFirst().data().containsKey("location_status"));
}
@Test void auditLocationCaptureFailureAndFailedSaveNeverClaimHealing() {
var f = new Direct(); f.automatic = false;
var captured = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
f.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(captured::add, f.clock), ignored -> BigInteger.ZERO);
when(f.player.getLocation()).thenThrow(new IllegalStateException("location inaccessible"));
f.recovery.sweep(); f.clock.advance(Duration.ofSeconds(10));
assertDoesNotThrow(f.recovery::sweep);
assertNotNull(f.pending);
f.pending.completeExceptionally(new java.io.IOException("save failed")); f.runNext();
assertEquals(4, f.health.get());
assertEquals(List.of("action.observed", "action.resolved"),
captured.stream().map(games.dmg.fantasystick.events.EventInput::type).toList());
assertEquals("failed", captured.getLast().data().get("outcome"));
assertEquals("unavailable", captured.getLast().data().get("location_status"));
}
@Test void passiveBonusZeroCancellationAndFullHealthStayInTheBasePulseTransaction() {
var zero = new Direct(); zero.recovery.attachBonusHealing(ignored -> BigInteger.ZERO);
zero.recovery.sweep(); zero.clock.advance(Duration.ofSeconds(10)); zero.recovery.sweep(); zero.runNext();
@@ -168,6 +240,48 @@ class OutOfCombatRecoveryNativeTest {
}
}
@Test void registeredRecoveryWithUnloadedAuditWorldPersistsAndExportsUnavailableLocation() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"out-of-combat-healing-delay-seconds: 0\nout-of-combat-healing-interval-seconds: 10\n");
java.util.UUID playerId; java.util.UUID characterId;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); setHealth(data, a, BigDecimal.valueOf(4));
playerId = a.core.id; characterId = a.core.record.id();
f.players.put(playerId, a.player); f.activate(a);
var location = mock(org.bukkit.Location.class);
when(location.getWorld()).thenThrow(new IllegalArgumentException("World unloaded"));
doReturn(location).when(a.player).getLocation();
var recovery = (OutOfCombatRecovery) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecovery");
assertDoesNotThrow(recovery::sweep);
doReturn(new org.bukkit.Location(a.world, 0, 64, 0)).when(a.player).getLocation();
PartyBookNativeTest.drain(f);
assertEquals(5, a.player.getHealth());
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(0, BigDecimal.valueOf(5).compareTo(storage.find(characterId).get().orElseThrow().health()));
}
var healed = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
for (var segment : files.filter(file -> file.getFileName().toString().startsWith("events-")
&& file.getFileName().toString().endsWith(".jsonl")).toList()) {
for (var line : java.nio.file.Files.readAllLines(segment)) {
if (line.isBlank()) continue;
var event = com.google.gson.JsonParser.parseString(line).getAsJsonObject();
if (event.get("type").getAsString().endsWith("combat.healed")
&& event.getAsJsonObject("data").has("recovery_kind")) healed.add(event);
}
}
}
assertEquals(1, healed.size());
var event = healed.getFirst(); var payload = event.getAsJsonObject("data");
assertEquals(playerId.toString(), payload.get("player_id").getAsString());
assertEquals(characterId.toString(), payload.get("character_id").getAsString());
assertTrue(payload.has("session_id"));
assertEquals("unavailable", payload.get("location_status").getAsString());
assertFalse(payload.has("location"));
assertEquals("5.0", payload.get("health_after").getAsString());
}
@Test void packagedPluginConnectsZeroDelayRecoveryConfigurationAndRestart() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"out-of-combat-healing-delay-seconds: 0\nout-of-combat-healing-interval-seconds: 10\nout-of-combat-healing-amount: 1\n");
@@ -0,0 +1,267 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import games.dmg.fantasystick.persistence.PartySettings;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.bukkit.command.CommandSender;
/** Evidence must join the registered policy path to actual roster enforcement and exported JSONL. */
class PartyCapacityEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredReadAndAcknowledgedReductionAndIncreaseGovernActualJoinsAndExportPolicy() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", PartySettings.KEY});
assertTrue(admin.messages.getLast().contains("=6"));
var leader = PartyCapacityNativeTest.actor(f, data);
var member = PartyCapacityNativeTest.actor(f, data);
var third = PartyCapacityNativeTest.actor(f, data);
var roster = PartyLifecycleNativeTest.parties(f);
var party = roster.create(leader.core.id).orElseThrow();
var first = roster.invite(leader.core.id, party.id(), member.core.id).orElseThrow();
assertTrue(roster.accept(member.core.id, first.id()));
var second = roster.invite(leader.core.id, party.id(), third.core.id).orElseThrow();
change(f, admin, "2");
assertFalse(roster.accept(third.core.id, second.id()));
change(f, admin, "3");
assertTrue(roster.accept(third.core.id, second.id()));
change(f, admin, "2");
assertEquals(3, roster.partyOf(leader.core.id).orElseThrow().members().size());
assertTrue(roster.invite(leader.core.id, party.id(), java.util.UUID.randomUUID()).isEmpty());
for (int i = 0; i < 200 && config().size() < 4; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = config(); assertEquals(4, entries.size(), "Only exported JSONL proves event delivery");
assertEquals(List.of("6", "2", "3", "2"), entries.stream()
.map(e -> e.getAsJsonObject("data").get("effective").getAsString()).toList());
assertEquals(List.of("config.read", "config.changed", "config.changed", "config.changed"), entries.stream()
.map(e -> e.get("type").getAsString().substring(e.get("type").getAsString().lastIndexOf(".",
e.get("type").getAsString().lastIndexOf(".") - 1) + 1)).toList());
assertTrue(entries.stream().skip(1).allMatch(e -> e.getAsJsonObject("data")
.get("existing_rosters_grandfathered").getAsBoolean()));
}
@Test void deniedInvalidAndFailedSaveNeverExportAChangeOrModifyCapacity() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = false;
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", PartySettings.KEY});
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", PartySettings.KEY, "2"});
admin.allowed = true;
for (var invalid : List.of("0", "-2", "2.5", "2147483648", "not-a-number")) {
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", PartySettings.KEY, invalid});
assertTrue(admin.messages.getLast().contains("invalid"));
}
Files.createDirectory(data.resolve("config.yml"));
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", PartySettings.KEY, "2"});
for (int i = 0; i < 30 && !admin.messages.getLast().contains("save failed"); i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
assertTrue(admin.messages.getLast().contains("save failed"));
assertEquals(PartySettings.DEFAULT, ((PartySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "partySettings")).value());
f.plugin.setEnabled(false);
}
assertTrue(config().isEmpty(), "Refusals and failed writes have no feature-owned success");
}
@Test void outOfOrderSavedCallbacksAndRepeatedValueNeverPublishFalseChanges() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
while (!f.tasks.isEmpty()) f.tasks.remove().run();
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", PartySettings.KEY, "2"});
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", PartySettings.KEY, "3"});
var settings = (PartySettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "partySettings");
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(
settings, "worker");
java.util.concurrent.CompletableFuture.runAsync(() -> {}, worker).get(5, TimeUnit.SECONDS);
var first = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(first);
var second = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(second);
second.run(); first.run();
assertEquals("3", settings.value());
change(f, admin, "3");
for (int i = 0; i < 200 && config().isEmpty(); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = config(); assertEquals(1, entries.size());
var values = entries.getFirst().getAsJsonObject("data");
assertEquals("6", values.get("before").getAsString());
assertEquals("3", values.get("effective").getAsString());
assertEquals("future_join_checks", values.get("application_scope").getAsString());
assertNotNull(values.get("operation_id")); assertNotNull(values.get("policy_revision"));
}
@Test void physicalAdministratorReadReportsExactIdentityLocationAndDefaultWithoutRosters() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
doReturn("Administrator").when(admin.player).getName();
f.players.put(admin.core.id, admin.player);
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"config", PartySettings.KEY});
for (int i = 0; i < 200 && config().isEmpty(); i++) Thread.sleep(10);
var observed = config(); assertEquals(1, observed.size());
var data = observed.getFirst().getAsJsonObject("data");
assertEquals(admin.core.id.toString(), data.get("player_id").getAsString());
assertEquals(admin.core.location.getX(), data.getAsJsonObject("location").get("x").getAsDouble());
f.plugin.setEnabled(false);
}
var entries = config(); assertEquals(1, entries.size());
var values = entries.getFirst().getAsJsonObject("data");
assertEquals("6", values.get("default").getAsString());
assertEquals("6", values.get("effective").getAsString());
assertEquals("player", values.get("actor_kind").getAsString());
assertEquals("Administrator", values.get("player_name").getAsString());
assertEquals("future_join_checks", values.get("application_scope").getAsString());
assertNotNull(values.getAsJsonObject("location").get("x"));
}
@Test void unavailableWriterRetainsSingleAcceptedPolicyIntentAcrossRestartWithoutAnotherWrite() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender();
var log = (games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog");
log.close(); change(f, admin, "2");
assertEquals("2", ((PartySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "partySettings")).value());
f.plugin.setEnabled(false);
assertTrue(config().isEmpty(), "Only exported segments count as evidence");
f.plugin.setEnabled(true);
for (int i = 0; i < 200 && config().isEmpty(); i++) Thread.sleep(10);
assertEquals("2", ((PartySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "partySettings")).value());
f.plugin.setEnabled(false);
}
var entries = config(); assertEquals(1, entries.size());
assertEquals("2", entries.getFirst().getAsJsonObject("data").get("effective").getAsString());
}
@Test void auditStoreFailureCannotRollBackAnAcknowledgedLimitOrMutateGrandfatheredRoster() throws Exception {
Files.writeString(data.resolve("party-capacity-event-intents"), "blocked");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var leader = PartyCapacityNativeTest.actor(f, data);
var member = PartyCapacityNativeTest.actor(f, data);
var third = PartyCapacityNativeTest.actor(f, data);
var roster = PartyLifecycleNativeTest.parties(f);
var party = roster.create(leader.core.id).orElseThrow();
assertTrue(roster.accept(member.core.id, roster.invite(leader.core.id, party.id(), member.core.id)
.orElseThrow().id()));
assertTrue(roster.accept(third.core.id, roster.invite(leader.core.id, party.id(), third.core.id)
.orElseThrow().id()));
var admin = new EquipmentAdminCommandNativeTest.Sender();
change(f, admin, "2");
assertEquals("2", ((PartySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "partySettings")).value());
assertEquals(3, roster.partyOf(leader.core.id).orElseThrow().members().size());
assertTrue(roster.invite(leader.core.id, party.id(), java.util.UUID.randomUUID()).isEmpty());
f.plugin.setEnabled(false);
}
assertTrue(config().isEmpty(), "Intent-store failure cannot fabricate an exported success");
}
@Test void callbackAfterCloseDoesNotInventAnUnpublishedRuntimeChange() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var settings = (PartySettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "partySettings");
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(
settings, "worker");
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
worker.execute(() -> {
entered.countDown();
try { release.await(); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
});
assertTrue(entered.await(5, TimeUnit.SECONDS));
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", PartySettings.KEY, "2"});
try { f.plugin.setEnabled(false); } finally { release.countDown(); }
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
while (!f.tasks.isEmpty()) f.tasks.remove().run();
assertEquals("6", settings.value()); assertTrue(config().isEmpty());
f.plugin.setEnabled(true);
assertEquals("2", ((PartySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "partySettings")).value());
f.plugin.setEnabled(false);
}
assertTrue(config().isEmpty(), "Startup must not fabricate an earlier runtime publication");
}
@Test void failedFeedbackCannotReverseAcknowledgedLimitOrOwnedEvent() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
CommandSender broken = FantasyStickLifecycleNativeTest.port(CommandSender.class, (name, args) -> {
return switch (name) {
case "hasPermission" -> true;
case "sendMessage" -> throw new IllegalStateException("feedback delivery failed");
default -> throw new AssertionError("Unexpected sender call " + name);
};
});
f.adminCommand.execute(broken, "fstickadmin", new String[]{"config", PartySettings.KEY, "2"});
var settings = (PartySettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "partySettings");
for (int i = 0; i < 30 && !"2".equals(settings.value()); i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback);
try { callback.run(); } catch (IllegalStateException feedback) {
assertEquals("feedback delivery failed", feedback.getMessage());
}
}
assertEquals("2", settings.value());
for (int i = 0; i < 200 && config().isEmpty(); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
assertEquals(1, config().size());
assertEquals("2", config().getFirst().getAsJsonObject("data").get("effective").getAsString());
}
private void change(FantasyStickLifecycleNativeTest.Fixture f,
EquipmentAdminCommandNativeTest.Sender admin, String value) throws Exception {
int before = admin.messages.size();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", PartySettings.KEY, value});
for (int i = 0; i < 30 && admin.messages.size() == before; i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
assertTrue(admin.messages.getLast().startsWith("Saved "), admin.messages.toString());
}
private List<JsonObject> config() throws Exception {
var entries = new ArrayList<JsonObject>(); var root = data.resolve("events");
if (!Files.isDirectory(root)) return entries;
try (var days = Files.list(root)) {
for (var day : days.filter(p -> p.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")
&& Files.isDirectory(p, java.nio.file.LinkOption.NOFOLLOW_LINKS)).toList()) {
try (var segments = Files.list(day)) {
for (var path : segments.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList()) {
var content = Files.readString(path); int end = content.lastIndexOf('\n');
if (end < 0) continue;
for (var line : content.substring(0, end).lines().toList()) if (!line.isBlank()) {
var event = JsonParser.parseString(line).getAsJsonObject();
if (event.get("type").getAsString().endsWith("config.read")
|| event.get("type").getAsString().endsWith("config.changed")) {
var values = event.getAsJsonObject("data");
if (values.has("key") && PartySettings.KEY.equals(values.get("key").getAsString()))
entries.add(event);
}
}
}
}
}
}
return entries;
}
}
@@ -0,0 +1,344 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import games.dmg.fantasystick.persistence.RoleplaySettings;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.bukkit.command.CommandSender;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Availability is a saved policy and exit request, not a claim that every restoration completed. */
class RoleplayAvailabilityEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredReadAndDisableRequestTwoOrdinaryExitsWithoutClaimingCompletedRestoration() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY});
assertTrue(admin.messages.getLast().contains("=false"));
var one = new FantasyStickLifecycleNativeTest.Actor(data);
var two = new FantasyStickLifecycleNativeTest.Actor(data);
var ordinary = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(one.core.id, one.player); f.players.put(two.core.id, two.player);
f.players.put(ordinary.core.id, ordinary.player);
f.activate(one); f.activate(two);
var ordinaryBefore = ordinary.core.snapshot();
change(f, admin, "true");
assertArrayEquals(ordinaryBefore, ordinary.core.snapshot());
var policy = (RoleplaySettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "roleplaySettings");
assertTrue(policy.disabled());
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
assertTrue(sessions.active(one.core.id).isEmpty()); assertTrue(sessions.active(two.core.id).isEmpty());
f.awaitCharacterAndReceipt(one); f.awaitCharacterAndReceipt(two);
assertNotNull(f.receipts().find(one.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit());
assertNotNull(f.receipts().find(two.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit());
var blocked = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(blocked.core.id, blocked.player);
var original = blocked.core.snapshot();
f.changed(blocked); PartyBookNativeTest.drain(f);
assertTrue(sessions.active(blocked.core.id).isEmpty());
assertArrayEquals(original, blocked.core.snapshot());
for (int i = 0; i < 200 && config().size() < 3; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var changes = config();
assertEquals(List.of("config.read", "config.changed", "game.availability_changed"), changes.stream()
.map(event -> event.get("type").getAsString().replace("games.dmg.minecraft.fantasystick.", ""))
.toList());
var config = changes.get(1).getAsJsonObject("data");
var availability = changes.get(2).getAsJsonObject("data");
assertEquals("false", config.get("before").getAsString());
assertEquals("true", config.get("effective").getAsString());
assertEquals(config.get("operation_id").getAsString(), availability.get("origin_admin_action_id").getAsString());
assertEquals(2, availability.get("exit_requested_count").getAsInt());
assertEquals(2, availability.get("exit_request_accepted_count").getAsInt());
assertEquals(0, availability.get("exit_confirmed_count").getAsInt(),
"The synchronous policy callback cannot attest later receipt completion");
assertEquals(2, availability.get("exit_pending_count").getAsInt());
assertEquals(0, availability.get("exit_failed_count").getAsInt());
assertTrue(availability.get("available_before").getAsBoolean());
assertFalse(availability.get("available_after").getAsBoolean());
}
@Test void failedNativeRestoreIsCountedSeparatelyFromPendingAndNeverConfirmedByToggle() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender();
var failed = new FantasyStickLifecycleNativeTest.Actor(data);
var intact = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(failed.core.id, failed.player); f.players.put(intact.core.id, intact.player);
f.activate(failed); f.activate(intact);
failed.core.mutation = () -> { throw new IllegalStateException("native restoration refused"); };
change(f, admin, "true");
failed.core.mutation = () -> {};
var failedReceipt = f.receipts().find(failed.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow();
assertNotNull(failedReceipt.pending(), "Failed native custody retains proof for recovery");
f.awaitCharacterAndReceipt(intact);
assertNotNull(f.receipts().find(intact.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit());
for (int i = 0; i < 200 && config().size() < 2; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var result = config().stream().filter(event -> event.get("type").getAsString()
.endsWith("game.availability_changed")).findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(2, result.get("exit_requested_count").getAsInt());
assertEquals(1, result.get("exit_failed_count").getAsInt());
assertEquals(1, result.get("exit_pending_count").getAsInt());
assertEquals(1, result.get("exit_native_restored_pending_receipt_count").getAsInt());
assertEquals(0, result.get("exit_confirmed_count").getAsInt());
}
@Test void deniedInvalidOrFailedSaveCannotExportReadChangeOrAvailability() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender();
var active = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(active.core.id, active.player); f.activate(active);
admin.allowed = false;
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY});
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY, "true"});
admin.allowed = true;
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY, "TRUE"});
assertTrue(admin.messages.getLast().contains("true|false"));
Files.createDirectory(data.resolve("config.yml"));
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY, "true"});
for (int i = 0; i < 20 && !admin.messages.getLast().contains("save failed"); i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
assertTrue(admin.messages.getLast().contains("save failed"));
assertFalse(((RoleplaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "roleplaySettings")).disabled());
assertTrue(((EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "sessions")).active(active.core.id).isPresent());
f.plugin.setEnabled(false);
}
assertTrue(config().isEmpty());
}
@Test void repeatedSettingDoesNotDuplicateAvailabilityAndReenableHasNoExitRequest() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender();
var active = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(active.core.id, active.player); f.activate(active);
change(f, admin, "true");
f.awaitCharacterAndReceipt(active);
change(f, admin, "true");
change(f, admin, "false");
for (int i = 0; i < 200 && config().size() < 4; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = config();
assertEquals(4, entries.size(), "Read-free true/false changes have one config and one availability record each");
var availability = entries.stream().filter(e -> e.get("type").getAsString()
.endsWith("game.availability_changed")).map(e -> e.getAsJsonObject("data")).toList();
assertEquals(2, availability.size());
assertEquals(1, availability.getFirst().get("exit_requested_count").getAsInt());
assertEquals(0, availability.getLast().get("exit_requested_count").getAsInt());
assertFalse(availability.getFirst().get("available_after").getAsBoolean());
assertTrue(availability.getLast().get("available_after").getAsBoolean());
}
@Test void unavailableEventWriterRetainsOnePolicyTransitionForRestartReplayWithoutNewExits() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender();
var log = (games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog");
log.close();
change(f, admin, "true");
f.plugin.setEnabled(false);
assertTrue(config().isEmpty(), "Pending intents are not exported day-segment evidence");
f.plugin.setEnabled(true);
for (int i = 0; i < 200 && config().size() < 2; i++) Thread.sleep(10);
assertTrue(((RoleplaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "roleplaySettings")).disabled());
f.plugin.setEnabled(false);
}
var entries = config(); assertEquals(2, entries.size());
var changed = entries.stream().filter(e -> e.get("type").getAsString().endsWith("config.changed"))
.findFirst().orElseThrow().getAsJsonObject("data");
var availability = entries.stream().filter(e -> e.get("type").getAsString()
.endsWith("game.availability_changed")).findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(changed.get("operation_id").getAsString(),
availability.get("origin_admin_action_id").getAsString());
assertEquals(0, availability.get("exit_requested_count").getAsInt());
}
@Test void reversedSavedCallbacksCannotPublishSupersededDisableOrInventAnAvailabilityChange() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
for (int i = 0; i < 30 && !f.tasks.isEmpty(); i++) f.tasks.remove().run();
var admin = new EquipmentAdminCommandNativeTest.Sender();
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id, actor.player); f.activate(actor);
f.awaitCharacterAndReceipt(actor); PartyBookNativeTest.drain(f);
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY, "true"});
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY, "false"});
var policy = (RoleplaySettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "roleplaySettings");
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(
policy, "worker");
java.util.concurrent.CompletableFuture.runAsync(() -> {}, worker).get(5, TimeUnit.SECONDS);
var first = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(first);
var second = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(second);
second.run(); first.run();
assertFalse(policy.disabled());
assertTrue(((EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "sessions")).active(actor.core.id).isPresent());
f.plugin.setEnabled(false);
}
assertTrue(config().isEmpty(), "No effective availability transition was published");
}
@Test void administratorReadCapturesRealPlayerAndLocationWithoutPublishingPolicy() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
doReturn("Administrator").when(admin.player).getName();
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
f.players.put(admin.core.id, admin.player);
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"config", RoleplaySettings.KEY});
for (int i = 0; i < 200 && config().isEmpty(); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
var events = config(); assertEquals(1, events.size());
var read = events.getFirst().getAsJsonObject("data");
assertEquals(admin.core.id.toString(), read.get("player_id").getAsString());
assertEquals("Administrator", read.get("player_name").getAsString());
assertEquals("player", read.get("actor_kind").getAsString());
assertFalse(read.get("effective").getAsBoolean());
assertEquals(admin.core.location.getX(), read.getAsJsonObject("location").get("x").getAsDouble());
}
assertFalse(Files.exists(data.resolve("config.yml")));
}
@Test void closedLifetimeCannotPublishOrReplayAnOldAvailabilitySuccess() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var old = (RoleplaySettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "roleplaySettings");
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(
old, "worker");
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
worker.execute(() -> {
entered.countDown();
try { release.await(); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
});
assertTrue(entered.await(5, TimeUnit.SECONDS));
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY, "true"});
try { f.plugin.setEnabled(false); } finally { release.countDown(); }
old.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
while (!f.tasks.isEmpty()) f.tasks.remove().run();
assertFalse(old.disabled(), "The disabled old command did not publish a late callback");
assertTrue(config().isEmpty());
f.plugin.setEnabled(true);
for (int i = 0; i < 40 && FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions") == null; i++)
f.tick();
assertTrue(((RoleplaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "roleplaySettings")).disabled(), "The new boot loads the saved policy");
f.plugin.setEnabled(false);
}
assertTrue(config().isEmpty(), "Startup must not invent an old-lifetime admin action");
}
@Test void feedbackFailureCannotRollBackAcknowledgedPolicyOrItsAvailabilityRecords() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id, actor.player); f.activate(actor);
CommandSender broken = FantasyStickLifecycleNativeTest.port(CommandSender.class, (name, args) -> {
return switch (name) {
case "hasPermission" -> true;
case "sendMessage" -> throw new IllegalStateException("feedback delivery failed");
default -> throw new AssertionError("Unexpected sender call " + name);
};
});
f.adminCommand.execute(broken, "fstickadmin", new String[]{"config", RoleplaySettings.KEY, "true"});
for (int i = 0; i < 30 && !((RoleplaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "roleplaySettings")).disabled(); i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback);
try { callback.run(); } catch (IllegalStateException feedback) {
assertEquals("feedback delivery failed", feedback.getMessage());
}
}
f.awaitCharacterAndReceipt(actor);
for (int i = 0; i < 200 && config().size() < 2; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = config(); assertEquals(2, entries.size());
assertEquals(1, entries.stream().filter(e -> e.get("type").getAsString()
.endsWith("game.availability_changed")).count());
assertTrue(entries.stream().anyMatch(e -> e.get("type").getAsString().endsWith("config.changed")));
}
@Test void auditStoreFailureDoesNotPreventSavedDisableAndNativeExit() throws Exception {
Files.writeString(data.resolve("roleplay-availability-event-intents"), "unavailable");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id, actor.player); f.activate(actor);
var admin = new EquipmentAdminCommandNativeTest.Sender();
change(f, admin, "true");
f.awaitCharacterAndReceipt(actor);
assertTrue(((RoleplaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "roleplaySettings")).disabled());
assertTrue(((EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "sessions")).active(actor.core.id).isEmpty());
f.plugin.setEnabled(false);
}
assertTrue(config().isEmpty(), "An unavailable audit store cannot undo or claim the real transition");
}
private static void change(FantasyStickLifecycleNativeTest.Fixture f,
EquipmentAdminCommandNativeTest.Sender admin, String value) throws Exception {
int responses = admin.messages.size();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY, value});
for (int i = 0; i < 30 && admin.messages.size() == responses; i++) {
var task = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(task); task.run();
}
assertTrue(admin.messages.getLast().startsWith("Saved "), admin.messages.toString());
}
private List<JsonObject> config() throws Exception {
var events = new ArrayList<JsonObject>();
var root = data.resolve("events");
if (!Files.isDirectory(root)) return events;
try (var days = Files.list(root)) {
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")
&& Files.isDirectory(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)).toList()) {
try (var segments = Files.list(day)) {
for (var path : segments.filter(file -> file.getFileName().toString().endsWith(".jsonl")).toList()) {
var content = Files.readString(path);
int end = content.lastIndexOf('\n');
if (end < 0) continue;
for (var line : content.substring(0, end).lines().toList()) if (!line.isBlank()) {
var event = JsonParser.parseString(line).getAsJsonObject();
var type = event.get("type").getAsString();
if (type.endsWith("game.availability_changed")
|| (type.endsWith("config.read") || type.endsWith("config.changed"))
&& event.getAsJsonObject("data").has("key")
&& event.getAsJsonObject("data").get("key").getAsString().equals(RoleplaySettings.KEY))
events.add(event);
}
}
}
}
}
return events;
}
}
@@ -0,0 +1,384 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import games.dmg.fantasystick.persistence.SheetResourcePackSettings;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.bukkit.event.player.PlayerResourcePackStatusEvent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Native optional delivery and actual status callbacks, not a cosmetic or request-only inference. */
class SheetResourcePackEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void startupNativeRequestAndRealAcceptedThenLoadedCallbacksExportDistinctEvents() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var player = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(player.core.id, player.player);
f.plugin.setEnabled(true);
assertEquals(1, player.resourcePacks.size());
var request = SheetResourcePackNativeTest.request(player.resourcePacks.getFirst());
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SheetResourcePackSettings.URL_KEY});
f.dispatcher.accept(new PlayerResourcePackStatusEvent(player.player, request.id(),
PlayerResourcePackStatusEvent.Status.ACCEPTED));
f.dispatcher.accept(new PlayerResourcePackStatusEvent(player.player, request.id(),
PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));
for (int i = 0; i < 200 && events().size() < 4; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = events(); assertEquals(4, entries.size(), "Native delivery/status/read must reach exported JSONL");
assertEquals(1, entries.stream().filter(e -> e.get("type").getAsString().endsWith("delivery_requested")).count());
assertEquals(2, entries.stream().filter(e -> e.get("type").getAsString().endsWith("delivery_resolved")).count());
assertEquals(1, entries.stream().filter(e -> e.get("type").getAsString().endsWith("config.read")).count());
}
@Test void acknowledgedRedeliveryUsesDistinctRequestIdentitySoLateOldStatusCannotClaimNewLoad() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var player = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(player.core.id, player.player); f.plugin.setEnabled(true);
var first = SheetResourcePackNativeTest.request(player.resourcePacks.getFirst());
var admin = new EquipmentAdminCommandNativeTest.Sender();
var url = "https://packs.example.test/sheet-v2.zip?secret=do-not-export";
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"config", SheetResourcePackSettings.URL_KEY, url});
for (int i = 0; i < 30 && player.resourcePacks.size() < 2; i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
var second = SheetResourcePackNativeTest.request(player.resourcePacks.getLast());
assertNotEquals(first.id(), second.id(), "A status exposes only pack ID; each offer needs a distinct ID");
f.dispatcher.accept(new PlayerResourcePackStatusEvent(player.player, first.id(),
PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));
f.dispatcher.accept(new PlayerResourcePackStatusEvent(player.player, second.id(),
PlayerResourcePackStatusEvent.Status.DECLINED));
for (int i = 0; i < 200 && events().size() < 4; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
assertTrue(events().stream().noneMatch(e -> e.toString().contains("do-not-export")));
var requested = events().stream().filter(e -> e.get("type").getAsString().endsWith("delivery_requested")).toList();
assertEquals(2, requested.size());
var statuses = events().stream().filter(e -> e.get("type").getAsString().endsWith("delivery_resolved")).toList();
assertEquals(1, statuses.size(), "A late prior-request callback cannot claim current client load");
assertEquals("declined", statuses.getFirst().getAsJsonObject("data").get("status").getAsString());
}
@Test void reloadCannotAssignAnOldLifetimeStatusToANewStartupOffer() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var player = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(player.core.id, player.player);
f.plugin.setEnabled(true);
var old = SheetResourcePackNativeTest.request(player.resourcePacks.getLast());
f.plugin.setEnabled(false);
f.plugin.setEnabled(true);
var current = SheetResourcePackNativeTest.request(player.resourcePacks.getLast());
assertNotEquals(old.id(), current.id(), "Old callbacks must never identify a new lifetime's offer");
f.dispatcher.accept(new PlayerResourcePackStatusEvent(player.player, old.id(),
PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));
for (int i = 0; i < 200 && events().size() < 2; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
assertEquals(2, events().stream().filter(e -> e.get("type").getAsString()
.endsWith("delivery_requested")).count());
assertTrue(events().stream().noneMatch(e -> e.get("type").getAsString()
.endsWith("delivery_resolved")));
}
@Test void joinAndNativeOutcomesAreDistinctDeduplicatedAndNeverImplyAnUnobservedLoad() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var player = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(player.core.id, player.player);
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(player.player,
(net.kyori.adventure.text.Component)null));
var request = SheetResourcePackNativeTest.request(player.resourcePacks.getFirst());
for (var status : List.of(PlayerResourcePackStatusEvent.Status.ACCEPTED,
PlayerResourcePackStatusEvent.Status.DOWNLOADED,
PlayerResourcePackStatusEvent.Status.FAILED_DOWNLOAD,
PlayerResourcePackStatusEvent.Status.FAILED_DOWNLOAD))
f.dispatcher.accept(new PlayerResourcePackStatusEvent(player.player, request.id(), status));
f.dispatcher.accept(new PlayerResourcePackStatusEvent(player.player, java.util.UUID.randomUUID(),
PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));
for (int i = 0; i < 200 && events().size() < 4; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = events(); assertEquals(4, entries.size());
var requested = entries.stream().filter(e -> e.get("type").getAsString().endsWith("delivery_requested")).toList();
assertEquals("join", requested.getFirst().getAsJsonObject("data").get("trigger").getAsString());
var statuses = entries.stream().filter(e -> e.get("type").getAsString().endsWith("delivery_resolved")).toList();
assertEquals(List.of("accepted", "downloaded", "failed_download"), statuses.stream()
.map(e -> e.getAsJsonObject("data").get("status").getAsString()).toList());
assertTrue(statuses.stream().allMatch(e -> e.getAsJsonObject("data")
.get("origin_delivery_action_id").getAsString().equals(requested.getFirst().getAsJsonObject("data")
.get("action_id").getAsString())));
}
@Test void authorizedPhysicalReadsAndRefusalsNeverLeakUrlSecretsOrClaimFailedChanges() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(admin.core.id, admin.player);
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
doReturn("Administrator").when(admin.player).getName();
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"config", SheetResourcePackSettings.URL_KEY});
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"config", SheetResourcePackSettings.SHA1_KEY});
for (int i = 0; i < 200 && events().size() < 2; i++) Thread.sleep(10);
var reads = events(); assertEquals(2, reads.size());
assertEquals(admin.core.id.toString(), reads.getFirst().getAsJsonObject("data")
.get("player_id").getAsString());
assertEquals(admin.core.location.getX(), reads.getFirst().getAsJsonObject("data")
.getAsJsonObject("location").get("x").getAsDouble());
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", SheetResourcePackSettings.URL_KEY});
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", SheetResourcePackSettings.URL_KEY,
"https://packs.example.test/other.zip?secret=do-not-export"});
sender.allowed = true;
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", SheetResourcePackSettings.URL_KEY,
"http://packs.example.test/invalid.zip"});
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", SheetResourcePackSettings.SHA1_KEY,
"not-a-hash"});
Files.createDirectory(data.resolve("config.yml"));
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", SheetResourcePackSettings.URL_KEY,
"https://packs.example.test/other.zip?secret=do-not-export"});
for (int i = 0; i < 30 && !sender.messages.getLast().contains("save failed"); i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
assertTrue(sender.messages.getLast().contains("save failed"));
f.plugin.setEnabled(false);
}
assertEquals(2, events().size());
assertTrue(events().stream().noneMatch(e -> e.toString().contains("do-not-export")));
}
@Test void writerUnavailableRetainsOneAcknowledgedChangeForReplayAfterRestart() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var log = (games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog");
log.close();
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{
"config", SheetResourcePackSettings.SHA1_KEY, "a".repeat(40)});
for (int i = 0; i < 30 && sender.messages.isEmpty(); i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
assertTrue(sender.messages.getLast().startsWith("Saved "));
f.plugin.setEnabled(false);
assertTrue(events().stream().noneMatch(e -> e.get("type").getAsString().endsWith("config.changed")));
f.plugin.setEnabled(true);
for (int i = 0; i < 200 && events().stream().noneMatch(e -> e.get("type").getAsString()
.endsWith("config.changed")); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var changes = events().stream().filter(e -> e.get("type").getAsString().endsWith("config.changed")).toList();
assertEquals(1, changes.size());
assertEquals("a".repeat(40), changes.getFirst().getAsJsonObject("data").get("effective").getAsString());
}
@Test void closedOldCallbackAndFailedFeedbackNeverFabricateClientLoadOrReverseSavedPair() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
CommandSender broken = FantasyStickLifecycleNativeTest.port(CommandSender.class, (name, args) -> {
return switch (name) {
case "hasPermission" -> true;
case "sendMessage" -> throw new IllegalStateException("feedback delivery failed");
default -> throw new AssertionError("Unexpected sender call " + name);
};
});
f.adminCommand.execute(broken, "fstickadmin", new String[]{
"config", SheetResourcePackSettings.SHA1_KEY, "a".repeat(40)});
var settings = (SheetResourcePackSettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "sheetResourcePackSettings");
for (int i = 0; i < 30 && !"a".repeat(40).equals(settings.sha1()); i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback);
try { callback.run(); } catch (IllegalStateException feedback) {
assertEquals("feedback delivery failed", feedback.getMessage());
}
}
assertEquals("a".repeat(40), settings.sha1());
for (int i = 0; i < 200 && events().stream().noneMatch(e -> e.get("type").getAsString()
.endsWith("config.changed")); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
assertEquals(1, events().stream().filter(e -> e.get("type").getAsString().endsWith("config.changed")).count());
assertTrue(events().stream().noneMatch(e -> e.get("type").getAsString().endsWith("delivery_resolved")));
}
@Test void nativeOfferAndFeedbackFailureRemainOptionalAndCannotPreventStartup() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var player = new FantasyStickLifecycleNativeTest.Actor(data);
Player broken = FantasyStickLifecycleNativeTest.port(Player.class, (name, args) -> {
if (name.equals("addResourcePack")) throw new IllegalStateException("native offer failed");
if (name.equals("sendMessage")) throw new IllegalStateException("feedback failed");
var arguments = args == null ? new Object[0] : args;
var matching = java.util.Arrays.stream(Player.class.getMethods())
.filter(method -> method.getName().equals(name) && method.getParameterCount() == arguments.length)
.filter(method -> java.util.stream.IntStream.range(0, arguments.length)
.allMatch(i -> arguments[i] == null || method.getParameterTypes()[i].isInstance(arguments[i])
|| method.getParameterTypes()[i].isPrimitive()))
.findFirst().orElseThrow();
try { return matching.invoke(player.player, arguments); }
catch (ReflectiveOperationException failed) { throw new IllegalStateException(failed); }
});
f.players.put(player.core.id, broken);
assertDoesNotThrow(() -> f.plugin.setEnabled(true));
for (int i = 0; i < 200 && events().isEmpty(); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var requested = events().stream().filter(e -> e.get("type").getAsString().endsWith("delivery_requested")).toList();
assertEquals(1, requested.size());
assertEquals("native_offer_failed", requested.getFirst().getAsJsonObject("data").get("outcome").getAsString());
assertTrue(events().stream().noneMatch(e -> e.get("type").getAsString().endsWith("delivery_resolved")));
}
@Test void unavailableLocationIsExplicitlyQualifiedWithoutBlockingNativeOfferAndStatus() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var player = mock(Player.class);
when(player.isOnline()).thenReturn(true);
when(player.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(player.getName()).thenReturn("LocationUnavailable");
when(player.getLocation()).thenThrow(new IllegalArgumentException("World unloaded"));
var delivery = (games.dmg.fantasystick.presentation.SheetResourcePack)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sheetResourcePack");
delivery.deliver(player);
var offered = org.mockito.ArgumentCaptor.forClass(java.util.UUID.class);
verify(player).addResourcePack(offered.capture(), anyString(), any(byte[].class), anyString(), eq(false));
f.dispatcher.accept(new PlayerResourcePackStatusEvent(player, offered.getValue(),
PlayerResourcePackStatusEvent.Status.DECLINED));
for (int i = 0; i < 200 && events().size() < 2; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = events(); assertEquals(2, entries.size());
assertTrue(entries.stream().allMatch(e -> e.getAsJsonObject("data").has("location_status")
&& "unavailable".equals(e.getAsJsonObject("data").get("location_status").getAsString())));
assertTrue(entries.stream().allMatch(e -> !e.getAsJsonObject("data").has("location")));
}
@Test void failedAuditIntentCannotPreventOptionalDeliveryOrSaveBeforeRedelivery() throws Exception {
Files.writeString(data.resolve("sheet-resource-pack-event-intents"), "blocked");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var player = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(player.core.id, player.player);
f.plugin.setEnabled(true);
assertEquals(1, player.resourcePacks.size());
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"config", SheetResourcePackSettings.SHA1_KEY, "a".repeat(40)});
for (int i = 0; i < 30 && player.resourcePacks.size() < 2; i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
assertEquals(2, player.resourcePacks.size());
assertArrayEquals(java.util.HexFormat.of().parseHex("a".repeat(40)),
SheetResourcePackNativeTest.request(player.resourcePacks.getLast()).hash());
f.plugin.setEnabled(false);
}
assertTrue(events().isEmpty(), "The unavailable audit writer cannot manufacture exported evidence");
}
@Test void supersededSavedCallbacksNeverRedeliverOrRecordTheStalePair() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var player = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(player.core.id, player.player);
f.plugin.setEnabled(true);
f.awaitCharacterAndReceipt(player);
PartyBookNativeTest.drain(f);
while (!f.tasks.isEmpty()) f.tasks.remove().run();
var admin = new EquipmentAdminCommandNativeTest.Sender();
var callbacks = new java.util.concurrent.LinkedBlockingQueue<Runnable>();
var command = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "admin");
var scheduler = command.getClass().getDeclaredField("nextTick");
scheduler.setAccessible(true);
var original = scheduler.get(command);
try {
scheduler.set(command, (java.util.function.Consumer<Runnable>)callbacks::add);
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"config", SheetResourcePackSettings.SHA1_KEY, "a".repeat(40)});
var first = callbacks.poll(5, TimeUnit.SECONDS); assertNotNull(first);
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"config", SheetResourcePackSettings.SHA1_KEY, "b".repeat(40)});
var second = callbacks.poll(5, TimeUnit.SECONDS); assertNotNull(second);
second.run(); first.run();
} finally { scheduler.set(command, original); }
var settings = (SheetResourcePackSettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "sheetResourcePackSettings");
assertEquals("b".repeat(40), settings.sha1());
assertEquals(2, player.resourcePacks.size(), "Only the latest acknowledged policy redelivers");
for (int i = 0; i < 200 && events().stream().noneMatch(e -> e.get("type").getAsString()
.endsWith("config.changed")); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var changes = events().stream().filter(e -> e.get("type").getAsString().endsWith("config.changed")).toList();
assertEquals(1, changes.size());
assertEquals("b".repeat(40), changes.getFirst().getAsJsonObject("data").get("effective").getAsString());
}
@Test void closeBeforeSavedCallbackCannotClaimUnpublishedConfigOrDeliverPack() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var player = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(player.core.id, player.player);
f.plugin.setEnabled(true);
var settings = (SheetResourcePackSettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "sheetResourcePackSettings");
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(settings, "worker");
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
worker.execute(() -> {
entered.countDown();
try { release.await(); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
});
assertTrue(entered.await(5, TimeUnit.SECONDS));
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"config", SheetResourcePackSettings.SHA1_KEY, "a".repeat(40)});
try { f.plugin.setEnabled(false); } finally { release.countDown(); }
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
while (!f.tasks.isEmpty()) f.tasks.remove().run();
assertEquals(1, player.resourcePacks.size());
assertTrue(events().stream().noneMatch(e -> e.get("type").getAsString().endsWith("config.changed")));
f.plugin.setEnabled(true);
assertEquals("a".repeat(40), ((SheetResourcePackSettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "sheetResourcePackSettings")).sha1());
f.plugin.setEnabled(false);
}
assertTrue(events().stream().noneMatch(e -> e.get("type").getAsString().endsWith("config.changed")));
}
private List<JsonObject> events() throws Exception {
var entries = new ArrayList<JsonObject>(); var root = data.resolve("events");
if (!Files.isDirectory(root)) return entries;
try (var days = Files.list(root)) {
for (var day : days.filter(p -> p.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")
&& Files.isDirectory(p, java.nio.file.LinkOption.NOFOLLOW_LINKS)).toList()) {
try (var segments = Files.list(day)) {
for (var path : segments.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList()) {
var content = Files.readString(path); int end = content.lastIndexOf('\n');
if (end < 0) continue;
for (var line : content.substring(0, end).lines().toList()) if (!line.isBlank()) {
var event = JsonParser.parseString(line).getAsJsonObject();
var type = event.get("type").getAsString();
if (type.endsWith("resource_pack.delivery_requested")
|| type.endsWith("resource_pack.delivery_resolved")
|| (type.endsWith("config.read") || type.endsWith("config.changed"))
&& event.getAsJsonObject("data").has("key")
&& event.getAsJsonObject("data").get("key").getAsString().startsWith("sheet-resource-pack-"))
entries.add(event);
}
}
}
}
}
return entries;
}
}
@@ -2,7 +2,7 @@ package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;import static org.mockito.ArgumentMatchers.*;import static org.mockito.Mockito.*;import java.nio.file.Path;import java.util.*;import net.kyori.adventure.text.Component;import org.bukkit.event.player.PlayerResourcePackStatusEvent;import org.junit.jupiter.api.BeforeAll;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
class SheetResourcePackNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
record Request(UUID id,String url,byte[] hash,String prompt,boolean required){}
@Test void packagedStartupJoinAndStatusesUseOptionalNativeDeliveryWithoutGameplayMutation()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){var a=new FantasyStickLifecycleNativeTest.Actor(data);a.core.inventory.setItemInOffHand(null);f.players.put(a.core.id,a.player);var before=a.core.snapshot();f.plugin.setEnabled(true);f.awaitCharacterAndReceipt(a);PartyBookNativeTest.drain(f);assertEquals(1,a.resourcePacks.size());var request=request(a.resourcePacks.getFirst());assertEquals(games.dmg.fantasystick.presentation.SheetResourcePack.ID,request.id());assertEquals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.packagedDefaults().url(),request.url());assertEquals(20,request.hash().length);assertFalse(request.required());assertArrayEquals(before,a.core.snapshot());for(var status:List.of(PlayerResourcePackStatusEvent.Status.DECLINED,PlayerResourcePackStatusEvent.Status.FAILED_DOWNLOAD,PlayerResourcePackStatusEvent.Status.INVALID_URL,PlayerResourcePackStatusEvent.Status.FAILED_RELOAD,PlayerResourcePackStatusEvent.Status.DISCARDED)){f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),status));assertTrue(a.messages.getLast().toLowerCase().contains("cosmetic"));assertArrayEquals(before,a.core.snapshot());}int messages=a.messages.size();f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.ACCEPTED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DOWNLOADED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));assertEquals(messages,a.messages.size());var active=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(active.core.id,active.player);var legacyMeta=active.core.inventory.getItemInOffHand().getItemMeta();legacyMeta.setItemModel(null);active.core.inventory.getItemInOffHand().setItemMeta(legacyMeta);var activeBefore=active.core.snapshot();var expectedMeta=activeBefore[40].getItemMeta();expectedMeta.setItemModel(games.dmg.fantasystick.items.CharacterSheets.MODEL);activeBefore[40].setItemMeta(expectedMeta);var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.enter(active.player);awaitPacked(f,active);assertEquals(games.dmg.fantasystick.items.CharacterSheets.MODEL,active.core.inventory.getItemInOffHand().getItemMeta().getItemModel());sessions.exit(active.player);f.awaitCharacterAndReceipt(active);assertArrayEquals(activeBefore,active.core.snapshot());var retained=(games.dmg.fantasystick.presentation.SheetResourcePack)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sheetResourcePack");f.plugin.setEnabled(false);retained.status(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DECLINED));assertEquals(messages,a.messages.size());f.plugin.setEnabled(true);var late=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(late.core.id,late.player);f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(late.player,(Component)null));assertEquals(1,late.resourcePacks.size());}}
@Test void packagedStartupJoinAndStatusesUseOptionalNativeDeliveryWithoutGameplayMutation()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){var a=new FantasyStickLifecycleNativeTest.Actor(data);a.core.inventory.setItemInOffHand(null);f.players.put(a.core.id,a.player);var before=a.core.snapshot();f.plugin.setEnabled(true);f.awaitCharacterAndReceipt(a);PartyBookNativeTest.drain(f);assertEquals(1,a.resourcePacks.size());var request=request(a.resourcePacks.getFirst());assertNotNull(request.id(),"Every native offer has its own correlatable pack identity");assertEquals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.packagedDefaults().url(),request.url());assertEquals(20,request.hash().length);assertFalse(request.required());assertArrayEquals(before,a.core.snapshot());for(var status:List.of(PlayerResourcePackStatusEvent.Status.DECLINED,PlayerResourcePackStatusEvent.Status.FAILED_DOWNLOAD,PlayerResourcePackStatusEvent.Status.INVALID_URL,PlayerResourcePackStatusEvent.Status.FAILED_RELOAD,PlayerResourcePackStatusEvent.Status.DISCARDED)){f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),status));assertTrue(a.messages.getLast().toLowerCase().contains("cosmetic"));assertArrayEquals(before,a.core.snapshot());}int messages=a.messages.size();f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.ACCEPTED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DOWNLOADED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));assertEquals(messages,a.messages.size());var active=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(active.core.id,active.player);var legacyMeta=active.core.inventory.getItemInOffHand().getItemMeta();legacyMeta.setItemModel(null);active.core.inventory.getItemInOffHand().setItemMeta(legacyMeta);var activeBefore=active.core.snapshot();var expectedMeta=activeBefore[40].getItemMeta();expectedMeta.setItemModel(games.dmg.fantasystick.items.CharacterSheets.MODEL);activeBefore[40].setItemMeta(expectedMeta);var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.enter(active.player);awaitPacked(f,active);assertEquals(games.dmg.fantasystick.items.CharacterSheets.MODEL,active.core.inventory.getItemInOffHand().getItemMeta().getItemModel());sessions.exit(active.player);f.awaitCharacterAndReceipt(active);assertArrayEquals(activeBefore,active.core.snapshot());var retained=(games.dmg.fantasystick.presentation.SheetResourcePack)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sheetResourcePack");f.plugin.setEnabled(false);retained.status(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DECLINED));assertEquals(messages,a.messages.size());f.plugin.setEnabled(true);var late=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(late.core.id,late.player);f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(late.player,(Component)null));assertEquals(1,late.resourcePacks.size());}}
@Test void invalidStartupAndFailedRegisteredWriteNeverDeliverOrChangeEffectiveDefaults()throws Exception{int route=0;for(String value:List.of("sheet-resource-pack-url: 'http://bad.test/x.zip'\n","sheet-resource-pack-sha1: bad\n")){var dir=data.resolve("invalid-"+route++);java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),value);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty(),"native registration order "+f.order);assertTrue(f.tasks.isEmpty(),"queued tasks");}}var failed=data.resolve("failed");java.nio.file.Files.createDirectories(failed);try(var f=new FantasyStickLifecycleNativeTest.Fixture(failed)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(failed);f.players.put(a.core.id,a.player);java.nio.file.Files.createDirectory(failed.resolve("config.yml"));var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/x.zip"});var callback=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(callback);callback.run();assertTrue(sender.messages.getLast().contains("save failed"),sender.messages::toString);assertTrue(a.resourcePacks.isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url"});assertTrue(sender.messages.getLast().contains("git.garvis.dev"));}}
@Test void registeredConfigurationDurablyPublishesAndRedeliversWhileFailureRetainsPair()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);var sender=new EquipmentAdminCommandNativeTest.Sender();assertEquals(List.of("sheet-resource-pack-sha1","sheet-resource-pack-url"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-r"}));assertEquals(1,f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url",""}).size());sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-r"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/sheet.zip"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/x.zip","extra"});assertTrue(sender.messages.getLast().contains("Usage"));for(var pair:List.of(new String[]{"sheet-resource-pack-url","http://bad.test/x.zip"},new String[]{"sheet-resource-pack-sha1","bad"})){f.command.execute(sender.port,"fantasystick",new String[]{"config",pair[0],pair[1]});assertTrue(sender.messages.getLast().contains("invalid"));}save(f,sender,"sheet-resource-pack-url","https://packs.example.test/sheet.zip");assertEquals(1,a.resourcePacks.size());assertEquals("https://packs.example.test/sheet.zip",request(a.resourcePacks.getLast()).url());save(f,sender,"sheet-resource-pack-sha1","a".repeat(40));assertEquals(2,a.resourcePacks.size());assertArrayEquals(java.util.HexFormat.of().parseHex("a".repeat(40)),request(a.resourcePacks.getLast()).hash());f.plugin.setEnabled(false);f.plugin.setEnabled(true);f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url"});assertTrue(sender.messages.getLast().contains("https://packs.example.test/sheet.zip"));}}
static void awaitPacked(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor)throws Exception{long deadline=System.nanoTime()+java.util.concurrent.TimeUnit.SECONDS.toNanos(10);while(System.nanoTime()<deadline){var marker=actor.core.marker();if(marker!=null&&marker.endsWith("|PACKED"))return;var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();}fail("Session did not activate; notices="+actor.messages);}
@@ -0,0 +1,269 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import games.dmg.fantasystick.persistence.SwitchDelaySettings;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Registered administrator policy changes must be independently visible in exported day segments. */
class SwitchDelayAdminEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredReadAndAcknowledgedZeroThenNonzeroChangesReachExportedJsonlAndActualExits() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"config", SwitchDelaySettings.KEY});
assertTrue(admin.messages.getLast().contains("=900"));
var player = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(player.core.id, player.player); f.activate(player);
change(f, admin, "0");
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
sessions.exit(player.player); f.awaitCharacterAndReceipt(player);
var first = f.receipts().find(player.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit();
assertEquals(Duration.ZERO, Duration.between(first.exitedAt(), first.deadline()));
f.reenter(player);
change(f, admin, "37");
sessions.exit(player.player); f.awaitCharacterAndReceipt(player);
var second = f.receipts().find(player.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit();
assertEquals(Duration.ofSeconds(37), Duration.between(second.exitedAt(), second.deadline()));
assertEquals(first.exitedAt(), first.deadline(), "Changing policy did not rewrite an existing deadline");
for (int i = 0; i < 200 && config().size() < 3; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = config();
assertEquals(List.of("games.dmg.minecraft.fantasystick.config.read",
"games.dmg.minecraft.fantasystick.config.changed",
"games.dmg.minecraft.fantasystick.config.changed"), entries.stream()
.map(event -> event.get("type").getAsString()).toList());
assertEquals(List.of("900", "0", "37"), entries.stream()
.map(event -> event.getAsJsonObject("data").get("effective").getAsString()).toList());
assertEquals(List.of("900", "0"), entries.subList(1, 3).stream()
.map(event -> event.getAsJsonObject("data").get("before").getAsString()).toList());
assertTrue(entries.get(1).getAsJsonObject("data").get("zero_disabled").getAsBoolean());
assertFalse(entries.get(2).getAsJsonObject("data").get("zero_disabled").getAsBoolean());
}
@Test void deniedInvalidAndFailedWritesNeverClaimPolicyChange() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender();
admin.allowed = false;
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY});
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY, "0"});
admin.allowed = true;
for (var invalid : List.of("-1", "1.5", "not-a-number")) {
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY, invalid});
assertTrue(admin.messages.getLast().contains("invalid"));
}
Files.createDirectory(data.resolve("config.yml"));
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY, "0"});
for (int i = 0; i < 20 && !admin.messages.getLast().contains("save failed"); i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
assertTrue(admin.messages.getLast().contains("save failed"));
var settings = (SwitchDelaySettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "switchDelaySettings");
assertEquals("900", settings.value());
f.plugin.setEnabled(false);
}
assertTrue(config().isEmpty(), "A rejected request, read or save has no domain success");
}
@Test void concurrentAcknowledgedWritesReportEffectiveBeforeAndAfterWithoutSupersededSuccess() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY, "0"});
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY, "37"});
for (int i = 0; i < 30 && admin.messages.size() < 2; i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
assertEquals(2, admin.messages.size(), admin.messages.toString());
assertTrue(admin.messages.stream().allMatch(message -> message.startsWith("Saved ")));
for (int i = 0; i < 200 && config().size() < 2; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var changes = config(); assertEquals(2, changes.size());
assertEquals(List.of("900", "0"), changes.stream()
.map(event -> event.getAsJsonObject("data").get("before").getAsString()).toList());
assertEquals(List.of("0", "37"), changes.stream()
.map(event -> event.getAsJsonObject("data").get("effective").getAsString()).toList());
assertEquals(2, changes.stream().map(event -> event.getAsJsonObject("data")
.get("operation_id").getAsString()).distinct().count());
}
@Test void supersededSavedCallbackCannotExportASecondOrRollbackEffectiveChange() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
for (int i = 0; i < 30 && !f.tasks.isEmpty(); i++) f.tasks.remove().run();
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY, "0"});
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY, "37"});
var settings = (SwitchDelaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "switchDelaySettings");
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(
settings, "worker");
java.util.concurrent.CompletableFuture.runAsync(() -> {}, worker).get(5, TimeUnit.SECONDS);
var first = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(first);
var second = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(second);
second.run(); first.run();
assertEquals("37", settings.value());
for (int i = 0; i < 200 && config().isEmpty(); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var changes = config();
assertEquals(1, changes.size(), "The stale callback must not claim a policy publication");
assertEquals("900", changes.getFirst().getAsJsonObject("data").get("before").getAsString());
assertEquals("37", changes.getFirst().getAsJsonObject("data").get("effective").getAsString());
}
@Test void playerAdministratorReadCapturesExactIdentityAndLocationWithoutSaving() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
doReturn("Administrator").when(admin.player).getName();
f.players.put(admin.core.id, admin.player);
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY});
for (int i = 0; i < 200 && config().isEmpty(); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
var entries = config(); assertEquals(1, entries.size());
var read = entries.getFirst(); var values = read.getAsJsonObject("data");
assertTrue(read.get("type").getAsString().endsWith("config.read"));
assertEquals(admin.core.id.toString(), values.get("player_id").getAsString());
assertEquals("player", values.get("actor_kind").getAsString());
assertEquals("future_exit", values.get("application_scope").getAsString());
assertEquals("900", values.get("default").getAsString());
assertEquals("900", values.get("effective").getAsString());
assertEquals(admin.core.location.getX(), values.getAsJsonObject("location").get("x").getAsDouble());
}
assertFalse(Files.exists(data.resolve("config.yml")), "A read does not persist configuration");
}
@Test void failedIntentHandoffCannotUndoSavedPolicyAndReplaysStableOperationAfterRestart() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender();
var log = (games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog");
log.close();
change(f, admin, "0");
assertEquals("0", ((SwitchDelaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "switchDelaySettings")).value());
f.plugin.setEnabled(false);
assertTrue(config().isEmpty(), "No pending file counts as an exported event");
f.plugin.setEnabled(true);
for (int i = 0; i < 200 && config().isEmpty(); i++) Thread.sleep(10);
assertEquals("0", ((SwitchDelaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "switchDelaySettings")).value());
f.plugin.setEnabled(false);
}
assertEquals(1, config().size(), "One saved operation replays without another configuration request");
assertEquals("0", config().getFirst().getAsJsonObject("data").get("effective").getAsString());
}
@Test void auditIntentFailureCannotRollBackPolicyOrPreventActualZeroDurationExit() throws Exception {
Files.writeString(data.resolve("switch-config-event-intents"), "blocked");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender();
change(f, admin, "0");
var player = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(player.core.id, player.player); f.activate(player);
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
sessions.exit(player.player); f.awaitCharacterAndReceipt(player);
var exit = f.receipts().find(player.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit();
assertEquals(Duration.ZERO, Duration.between(exit.exitedAt(), exit.deadline()));
assertEquals("0", ((SwitchDelaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "switchDelaySettings")).value());
f.plugin.setEnabled(false);
}
assertTrue(config().isEmpty(), "Audit-store failure cannot invent an exported change");
}
@Test void closingBeforeAcknowledgedCallbackNeverLogsUnpublishedRuntimeChange() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var settings = (SwitchDelaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "switchDelaySettings");
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(
settings, "worker");
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
worker.execute(() -> {
entered.countDown();
try { release.await(); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
});
assertTrue(entered.await(5, TimeUnit.SECONDS));
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY, "0"});
try { f.plugin.setEnabled(false); } finally { release.countDown(); }
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
while (!f.tasks.isEmpty()) f.tasks.remove().run();
assertEquals("900", settings.value(), "Closed command cannot publish a late setting");
assertTrue(config().isEmpty(), "Persisted but unpublished work is not an acknowledged domain change");
f.plugin.setEnabled(true);
assertEquals("0", ((SwitchDelaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "switchDelaySettings")).value(), "The later boot loads the committed setting");
f.plugin.setEnabled(false);
}
assertTrue(config().isEmpty(), "Startup load does not invent a prior runtime config.changed");
}
private void change(FantasyStickLifecycleNativeTest.Fixture f,
EquipmentAdminCommandNativeTest.Sender admin, String value) throws Exception {
int messages = admin.messages.size();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY, value});
for (int i = 0; i < 30 && admin.messages.size() == messages; i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS);
assertNotNull(callback, "Acknowledged setting needs a native callback");
callback.run();
}
assertTrue(admin.messages.getLast().startsWith("Saved "), admin.messages.toString());
}
private List<JsonObject> config() throws Exception {
var entries = new ArrayList<JsonObject>();
var root = data.resolve("events");
if (!Files.isDirectory(root)) return entries;
try (var days = Files.list(root)) {
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")
&& Files.isDirectory(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)).toList()) {
try (var segments = Files.list(day)) {
for (var path : segments.filter(file -> file.getFileName().toString().endsWith(".jsonl")).toList()) {
var content = Files.readString(path);
int end = content.lastIndexOf('\n');
if (end < 0) continue;
for (var line : content.substring(0, end).lines().toList()) if (!line.isBlank()) {
var event = JsonParser.parseString(line).getAsJsonObject();
if (event.get("type").getAsString().endsWith("config.read")
|| event.get("type").getAsString().endsWith("config.changed")) {
var values = event.getAsJsonObject("data");
if (values.has("key") && values.get("key").getAsString().equals(SwitchDelaySettings.KEY))
entries.add(event);
}
}
}
}
}
}
return entries;
}
}