Compare commits

...
23 Commits
Author SHA1 Message Date
dmg 13828054e5 feat(events): audit combat feedback preferences
Release / release (push) Failing after 14m7s
2026-09-25 09:48:27 -04:00
dmg 39c58ef4d9 feat(events): audit account aura preferences
Release / release (push) Successful in 14m40s
2026-09-25 09:37:45 -04:00
dmg ec73018f93 feat(events): audit account particle preferences
Release / release (push) Successful in 14m30s
2026-09-25 09:21:18 -04:00
dmg b86f703d14 feat(events): record Meteor impact outcomes
Release / release (push) Successful in 12m56s
2026-09-25 03:19:10 -04:00
dmg 2231dd058a feat(events): record War Banner benefit outcomes
Release / release (push) Successful in 7m10s
2026-09-25 03:11:25 -04:00
dmg ff28f2e98d feat(events): record Sanctuary area and healing outcomes
Release / release (push) Successful in 7m19s
2026-09-25 03:02:45 -04:00
dmg 21877f6820 feat(events): record Chain Lightning strike outcomes
Release / release (push) Failing after 13m13s
2026-09-25 02:50:24 -04:00
dmg 228de3d08f feat(events): record Frost Nova burst outcomes
Release / release (push) Successful in 7m29s
2026-09-25 02:42:05 -04:00
dmg e3bc854481 feat(events): record Rallying Cry boost outcomes
Release / release (push) Successful in 13m10s
2026-09-25 02:28:10 -04:00
dmg 3163a491a0 feat(events): record Guardian Bond damage outcomes
Release / release (push) Successful in 13m31s
2026-09-25 02:18:00 -04:00
dmg 08d13d2192 feat(events): record Purify cleanse outcomes
Release / release (push) Successful in 13m17s
2026-09-25 02:01:08 -04:00
dmg 1d0cdc8f0f feat(events): record Mending Light healing outcomes
Release / release (push) Successful in 8m27s
2026-09-25 01:51:17 -04:00
dmg 9674a8e5e6 feat(events): record Fireball and configuration outcomes
Release / release (push) Successful in 14m20s
2026-09-25 01:41:25 -04:00
dmg dd75e6a76f feat(events): add opt-in ability cast event hooks
Release / release (push) Successful in 14m37s
2026-09-25 01:16:43 -04:00
dmg 22d92b5bbf feat(events): record character session and message outcomes
Release / release (push) Successful in 14m5s
2026-09-25 00:18:41 -04:00
dmg 26ccc95cdd feat(events): record character coin outcomes
Release / release (push) Successful in 14m24s
2026-09-24 23:57:29 -04:00
dmg 8ae8cdb603 feat(events): retain shared combat XP evidence
Release / release (push) Successful in 13m53s
2026-09-24 23:40:28 -04:00
dmg d18a41af6c feat(events): record combat reward resolutions
Release / release (push) Successful in 13m58s
2026-09-24 23:31:16 -04:00
dmg 6428ab999b feat(events): record persistent party outcomes
Release / release (push) Successful in 14m5s
2026-09-24 23:17:39 -04:00
dmg 6bb58cd52d feat(events): record complete Revive outcomes
Release / release (push) Successful in 13m59s
2026-09-24 22:44:16 -04:00
dmg 25eb6e3a6b feat(events): record passive recovery outcomes
Release / release (push) Successful in 13m53s
2026-09-24 22:26:45 -04:00
dmg 51b055da12 feat(events): record exact ability refunds
Release / release (push) Successful in 13m52s
2026-09-24 22:11:35 -04:00
dmg 60ccb34ab6 feat(events): record permanent stat mutations
Release / release (push) Successful in 14m32s
2026-09-24 22:00:02 -04:00
90 changed files with 4421 additions and 357 deletions

No files matched your search

@@ -163,10 +163,21 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
private games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
private games.dmg.fantasystick.events.LocalEventLog eventLog;
private games.dmg.fantasystick.events.PreferenceEventBridge preferenceEvents;
private games.dmg.fantasystick.events.EventCaptureListener eventCapture;
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
private games.dmg.fantasystick.events.RareEventBridge rareEvents;
private games.dmg.fantasystick.events.BookEventBridge bookEvents;
private games.dmg.fantasystick.events.ProgressionEventBridge progressionEvents;
private games.dmg.fantasystick.events.AbilityEventBridge abilityEvents;
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.PartyEventBridge partyAuditEvents;
private games.dmg.fantasystick.events.CombatRewardEventBridge combatRewardEvents;
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
private games.dmg.fantasystick.events.SessionEventBridge sessionAuditEvents;
private games.dmg.fantasystick.events.MessageEventBridge messageAuditEvents;
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
@Override public void onEnable() {
@@ -385,7 +396,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
net.kyori.adventure.text.Component.text(message), net.kyori.adventure.text.Component.empty()));
}
}));
sessions.attachPartyDefeat(characterParties::defeat);
sessions.attachPartyDefeat(character->{var before=characterParties.partyOf(character).orElse(null);return characterParties.defeat(character).thenApply(outcome->{var bridge=partyAuditEvents;if(bridge!=null)bridge.finalDefeat(character,before,outcome);return outcome;});});
var reconcilingParties = characterParties;
var partyCharacters = reconcilingParties.all().stream().flatMap(party -> java.util.stream.Stream.concat(
party.members().stream(), party.invitations().stream().map(games.dmg.fantasystick.party.CharacterParty.Invitation::character))).distinct().toList();
@@ -702,16 +713,50 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getVersion(), getServer().getMinecraftVersion(), getDescription().getVersion()), 1024, 134_217_728L,
message -> getLogger().warning(message));
eventCapture = new games.dmg.fantasystick.events.EventCaptureListener(eventLog, sessions, Clock.systemUTC());
preferenceEvents = new games.dmg.fantasystick.events.PreferenceEventBridge(
eventLog::emit, Clock.systemUTC(), sessions, feedbackPreferences,
failure -> getLogger().log(Level.WARNING, "Preference event publication proof failed", failure));
particlesCommand.attachEvents(preferenceEvents);
feedbackCommand.attachEvents(preferenceEvents);
eventCapture.excludeOwnedDamage(fireball::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(guardianBond::ownsAcceptedRedirect);
eventCapture.excludeOwnedDamage(frostNova::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(chainLightning::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(meteor::ownsAcceptedDamage);
downedEvents = new games.dmg.fantasystick.events.DownedEventBridge(eventLog::emit, Clock.systemUTC());
rareEvents = new games.dmg.fantasystick.events.RareEventBridge(eventLog::emit, Clock.systemUTC());rareShop.attachEvents(rareEvents);
bookEvents = new games.dmg.fantasystick.events.BookEventBridge(eventLog::emit, Clock.systemUTC());books.attachEvents(bookEvents);
progressionEvents = new games.dmg.fantasystick.events.ProgressionEventBridge(eventLog::emit, Clock.systemUTC());books.attachProgressionEvents(progressionEvents);
abilityEvents = new games.dmg.fantasystick.events.AbilityEventBridge(eventLog::emit, Clock.systemUTC());books.attachAbilityEvents(abilityEvents);
abilityEngine.attachCastEvents(new games.dmg.fantasystick.events.AbilityCastEventBridge(eventLog::emit, Clock.systemUTC()));
abilityEngine.enableCastEventsFor("fireball");
abilityEngine.enableCastEventsFor("mending_light");
abilityEngine.enableCastEventsFor("purify");
abilityEngine.enableCastEventsFor("guardian_bond");
abilityEngine.enableCastEventsFor("rallying_cry");
abilityEngine.enableCastEventsFor("frost_nova");
abilityEngine.enableCastEventsFor("chain_lightning");
abilityEngine.enableCastEventsFor("sanctuary");
abilityEngine.enableCastEventsFor("war_banner");
abilityEngine.enableCastEventsFor("meteor");
admin.attachAbilityConfigEvents(new games.dmg.fantasystick.events.AbilityConfigEventBridge(eventLog::emit, Clock.systemUTC()));
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);
partyAuditEvents = new games.dmg.fantasystick.events.PartyEventBridge(eventLog::emit, Clock.systemUTC());partyManagement.attachEvents(partyAuditEvents);admin.attachPartyEvents(partyAuditEvents);
combatRewardEvents = new games.dmg.fantasystick.events.CombatRewardEventBridge(eventLog::emit,Clock.systemUTC());combatExperience.attachEvents(combatRewardEvents);
coinEvents = new games.dmg.fantasystick.events.CoinEventBridge(eventLog::emit,Clock.systemUTC());characters.attachCoinEvents(coinEvents);admin.attachCoinEvents(coinEvents);
sessionAuditEvents = new games.dmg.fantasystick.events.SessionEventBridge(eventLog::emit,Clock.systemUTC());sessions.attachSessionEvents(sessionAuditEvents);
sessions.attachDownedEvents(downedEvents);
downedController.attachEvents(downedEvents);
revive.attachDownedEvents(downedEvents);
admin.attachDownedEvents(downedEvents);
getServer().getPluginManager().registerEvents(eventCapture, this);
messageAuditEvents = new games.dmg.fantasystick.events.MessageEventBridge(
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;
eventLog = null; eventCapture = 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; messageAuditEvents = null;
getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure);
}
getServer().getPluginManager().registerEvents(crafting, this);
@@ -791,7 +836,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
recipeDiscovery.discoverAll(onlinePlayers);
for (var player : onlinePlayers) { sessions.arrive(player); revive.reconcile(player); }
sheetResourcePack.deliverAll(onlinePlayers);
if(eventLog!=null)eventLog.ready();
if (eventLog != null) {
var preferenceReplay = preferenceEvents;
var savedPreferences = feedbackPreferences;
eventLog.ready().whenComplete((delivery, failure) -> {
if (failure == null && delivery != games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED
&& preferenceReplay != null && savedPreferences != null)
for (var pendingPreference : savedPreferences.pendingEvents()) preferenceReplay.replay(pendingPreference);
});
}
} catch (RuntimeException | LinkageError failure) {
try { onDisable(); }
catch (RuntimeException | LinkageError cleanupFailure) { failure.addSuppressed(cleanupFailure); }
@@ -813,8 +866,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
particleDelivery = null;
combatFeedback = null;
var closingEvents = sessionEvents; sessionEvents = null;
var closingEventLog = eventLog; eventLog = null;
var closingEventCapture = eventCapture; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null;
var closingEventLog = eventLog; eventLog = null; preferenceEvents = 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;
var closingBooks = books; books = null;
var closingPartyEvents = partyEvents; partyEvents = null;
var closingPartyCombat = partyCombat; partyCombat = null;
@@ -1122,6 +1175,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingModes);
unregister(closingChat);
unregister(closingEventCapture);
unregister(closingMessageAuditEvents);
unregister(closingPartyEvents);
unregister(closingPartyCombat);
unregister(closingPartyRegistrarController);
@@ -43,11 +43,32 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
private final games.dmg.fantasystick.training.TrainingDamage trainingDamage;
private final Consumer<Failure> failures;
private final Map<Key, Object> pending = new HashMap<>();
private final Set<String> eventOwnedAbilities = new HashSet<>();
private final Map<Key, Long> generations = new HashMap<>();
private final Map<Key, Set<Runtime>> active = new HashMap<>();
private org.bukkit.scheduler.BukkitTask sweepTask;
private games.dmg.fantasystick.events.AbilityCastEventBridge castEvents;
private boolean closed;
public void attachCastEvents(games.dmg.fantasystick.events.AbilityCastEventBridge events) {
if (closed || castEvents != null) throw new IllegalStateException("Cast events already attached or engine closed");
castEvents = Objects.requireNonNull(events);
}
/** An ability owner opts in only after its actual effect-specific event path is delivered. */
public void enableCastEventsFor(String abilityId) {
if (closed || registry.find(Objects.requireNonNull(abilityId)).isEmpty())
throw new IllegalArgumentException("Unknown or closed ability event owner");
eventOwnedAbilities.add(abilityId);
}
public boolean eventsEnabledFor(String abilityId) { return eventOwnedAbilities.contains(abilityId); }
private void resolve(games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit,
String outcome, String reason) {
if (castEvents != null) castEvents.resolved(audit, outcome, reason);
}
public AbilityEngine(AbilityRegistry registry, CharacterStorage characters, EquipmentSessions sessions,
PartyRoster parties, Clock clock, Consumer<Runnable> nextTick, AbilityScheduler scheduler,
Consumer<AbilityReport> reports, Consumer<Failure> failures) {
@@ -176,22 +197,31 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
pending.remove(key, token); report(key.character(), key.ability(), invalid);
input.player().sendMessage("That ability's cooldown is invalid."); return;
}
characters.commitAbilityCooldown(key.character(), record.revision(), key.ability(), readyAt)
.whenComplete((mutation, failure) -> {
try {
nextTick.accept(() -> completeCast(input, definition, record.revision(), readyAt,
key, token, generation, mutation, failure));
} catch (RuntimeException rejected) {
pending.remove(key, token); report(key.character(), key.ability(), rejected);
}
});
var audit = castEvents == null || !eventOwnedAbilities.contains(definition.id())
? null : castEvents.begin(input, record, definition);
try {
characters.commitAbilityCooldown(key.character(), record.revision(), key.ability(), readyAt)
.whenComplete((mutation, failure) -> {
try {
nextTick.accept(() -> completeCast(input, definition, record.revision(), readyAt,
key, token, generation, audit, mutation, failure));
} catch (RuntimeException rejected) {
pending.remove(key, token); resolve(audit, "failed", "scheduler_rejected");
report(key.character(), key.ability(), rejected);
}
});
} catch (RuntimeException failure) {
pending.remove(key, token); resolve(audit, "failed", "save_rejected");
report(key.character(), key.ability(), failure);
}
}
private void completeCast(ControlInput input, AbilityDefinition definition, BigInteger expectedRevision, Instant readyAt,
Key key, Object token, long generation, CharacterStorage.AbilityMutation mutation, Throwable failure) {
if (!pending.remove(key, token)) return;
if (closed) return;
Key key, Object token, long generation, games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit,
CharacterStorage.AbilityMutation mutation, Throwable failure) {
if (!pending.remove(key, token) || closed) { resolve(audit, "interrupted", "session_or_engine_closed"); return; }
if (failure != null) {
resolve(audit, "failed", "save_failed");
report(key.character(), key.ability(), failure);
input.player().sendMessage("The cast could not be saved; no effect was applied."); return;
}
@@ -199,6 +229,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
&& mutation.record().map(record -> readyAt.equals(record.cooldowns().get(key.ability()))).orElse(false);
if ((mutation.status() != CharacterStorage.AbilityMutationStatus.APPLIED && !unchangedDeadline)
|| mutation.record().isEmpty()) {
resolve(audit, "refused", mutation.status().name().toLowerCase(java.util.Locale.ROOT));
input.player().sendMessage(switch (mutation.status()) {
case COOLDOWN -> "That ability is still on cooldown.";
case STALE -> "That character changed; retry the cast.";
@@ -208,23 +239,26 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
return;
}
CharacterRecord saved = mutation.record().orElseThrow();
if (castEvents != null) castEvents.cast(audit, saved, readyAt, definition);
boolean synchronizedProgress = unchangedDeadline
? sessions.currentRecord(input.session()).filter(saved::equals).isPresent()
: sessions.acknowledgeProgress(input.session(), expectedRevision, saved);
if (!synchronizedProgress) {
resolve(audit, "interrupted", "session_publication_changed");
input.player().sendMessage("The cooldown was saved, but the session changed; no effect was applied."); return;
}
if (!input.stillCurrent() || generations.getOrDefault(key, 0L) != generation
|| !eligible(definition.targetPolicy(), input, input.target())) {
resolve(audit, "interrupted", "eligibility_changed");
input.player().sendMessage("The cooldown was saved, but eligibility changed; no effect was applied."); return;
}
var runtime = new Runtime(key, input, definition, generation);
var runtime = new Runtime(key, input, definition, generation, audit);
active.computeIfAbsent(key, ignored -> new HashSet<>()).add(runtime);
try {
definition.effect().activate(runtime);
if (definition.activation() == AbilityDefinition.Activation.IMMEDIATE) runtime.finish();
} catch (RuntimeException effectFailure) {
runtime.close(); report(key.character(), key.ability(), effectFailure);
runtime.resolve("failed"); runtime.close(); report(key.character(), key.ability(), effectFailure);
input.player().sendMessage("The cooldown was saved, but the ability effect failed safely.");
}
}
@@ -293,12 +327,18 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
private final ControlInput input;
private final AbilityDefinition definition;
private final long generation;
private final games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit;
private final Map<UUID, String> observedEffects = new HashMap<>();
private final Set<AutoCloseable> owned = new HashSet<>();
private String outcome;
private Map<String, Object> resolutionEvidence = Map.of();
private boolean finished;
private int scheduled;
Runtime(Key key, ControlInput input, AbilityDefinition definition, long generation) {
Runtime(Key key, ControlInput input, AbilityDefinition definition, long generation,
games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit) {
this.key = key; this.input = input; this.definition = definition; this.generation = generation;
this.audit = audit;
}
@Override public AbilityDefinition definition() { return definition; }
@Override public ControlInput input() { return input; }
@@ -361,12 +401,75 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
try { feedback.report(definition, input, type, target, report.amount()); }
catch (RuntimeException failure) { AbilityEngine.this.report(key.character(), key.ability(), failure); }
}
@Override public void finish() { close(); }
@Override public UUID effectApplied(String effectId, Entity target, Map<String, Object> evidence) {
if (!stillCurrent()) throw new IllegalStateException("Ability execution ended");
Objects.requireNonNull(effectId); Objects.requireNonNull(evidence);
UUID application = castEvents == null ? UUID.randomUUID() : castEvents.effectApplied(audit, effectId, target, evidence);
observedEffects.put(application, effectId);
return application;
}
@Override public void acceptedDamage(LivingEntity target, double requested, double actual,
Entity direct, Map<String, Object> evidence) {
if (castEvents == null || audit == null) return;
try {
var details = new java.util.LinkedHashMap<String, Object>(evidence);
if (target instanceof Player player)
sessions.active(player.getUniqueId()).filter(sessions::current).ifPresent(active -> {
details.put("target_character_id", active.character().toString());
details.put("target_session_id", active.session().toString());
});
castEvents.acceptedDamage(audit, target, requested, actual, direct, details);
} catch (RuntimeException ignored) { /* Recording cannot change accepted native damage. */ }
}
@Override public void acceptedHeal(EquipmentSessions.Active aided, Player target,
CharacterRecord saved, double requested, double actual,
double before, double after, double maximum) {
if (castEvents != null && audit != null)
castEvents.healed(audit, target, aided.character(), aided.session(), saved,
requested, actual, before, after, maximum);
}
@Override public void acceptedCleanse(EquipmentSessions.Active aided, Player target, List<String> removed) {
if (castEvents != null && audit != null)
castEvents.cleansed(audit, target, aided.character(), aided.session(), removed);
}
@Override public void acceptedPrevention(EquipmentSessions.Active aided, Player ally, Entity attacker,
double incoming, double actual, double remaining,
UUID bondApplication) {
if (castEvents != null && audit != null)
castEvents.prevented(audit, ally, aided.character(), aided.session(), attacker,
incoming, actual, remaining, bondApplication);
}
@Override public void acceptedBoost(EquipmentSessions.Active attacking, Player attacker,
Entity target, double base, double bonus, double finalDamage,
java.math.BigDecimal percent) {
if (castEvents != null && audit != null)
castEvents.boosted(audit, attacking, attacker, target, base, bonus, finalDamage, percent);
}
@Override public void effectEnded(UUID application, String reason) {
Objects.requireNonNull(application); Objects.requireNonNull(reason);
var effectId = observedEffects.remove(application);
if (effectId != null && castEvents != null) castEvents.effectEnded(audit, application, effectId, reason);
}
@Override public void resolve(String result) { resolve(result, Map.of()); }
@Override public void resolve(String result, Map<String, Object> evidence) {
if (!Set.of("completed", "hit", "miss", "expired", "interrupted", "failed", "cancelled",
"healed", "cleansed", "protected", "boosted").contains(result))
throw new IllegalArgumentException("Unsupported ability resolution");
if (!finished) { outcome = result; resolutionEvidence = Map.copyOf(Objects.requireNonNull(evidence)); }
}
@Override public void finish() {
if (outcome == null) outcome = "completed";
close();
}
@Override public void close() {
if (finished) return;
finished = true;
if (outcome == null) outcome = "interrupted";
var resources = List.copyOf(owned); owned.clear();
for (var resource : resources) try { resource.close(); } catch (Exception failure) { AbilityEngine.this.report(key.character(), key.ability(), failure); }
for (var application : List.copyOf(observedEffects.keySet())) effectEnded(application, outcome);
if (castEvents != null) castEvents.resolved(audit, outcome,
outcome.equals("completed") ? "effect_finished_without_hit_claim" : "effect_ended", resolutionEvidence);
var runtimes = active.get(key);
if (runtimes != null) { runtimes.remove(this); if (runtimes.isEmpty()) active.remove(key); }
}
@@ -5,6 +5,8 @@ import java.math.BigDecimal;
import java.time.Duration;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.bukkit.damage.DamageSource;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
@@ -21,6 +23,32 @@ public interface AbilityExecution extends AutoCloseable {
boolean trainingDummy(Entity target);
void damage(LivingEntity target, double amount, DamageSource source);
void report(AbilityReport.Type type, Entity target, BigDecimal amount);
/** An owner reports an effect only after its actual native application. */
UUID effectApplied(String effectId, Entity target, Map<String, Object> evidence);
/** Only the effect owner calls this after positive, uncancelled native damage is accepted. */
void acceptedDamage(LivingEntity target, double requested, double actual, Entity direct,
Map<String, Object> evidence);
/** Only after the aided record and native health have both been acknowledged. */
void acceptedHeal(games.dmg.fantasystick.session.EquipmentSessions.Active aided,
org.bukkit.entity.Player target,
games.dmg.fantasystick.persistence.CharacterRecord saved,
double requested, double actual, double before, double after, double maximum);
/** Record only harmful native effects actually removed from this target. */
void acceptedCleanse(games.dmg.fantasystick.session.EquipmentSessions.Active aided,
org.bukkit.entity.Player target, List<String> removedEffects);
/** Report only the final uncancelled native ally reduction, not a proposed redirect. */
void acceptedPrevention(games.dmg.fantasystick.session.EquipmentSessions.Active aided,
org.bukkit.entity.Player ally, Entity attacker, double incoming,
double actual, double remaining, UUID bondApplication);
/** 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,
double base, double bonus, double finalDamage, java.math.BigDecimal percent);
/** End an owned observed effect once; ordinary close ends any remaining observed effects. */
void effectEnded(UUID applicationId, String reason);
/** An owner may specify a truthful terminal outcome before finishing. */
void resolve(String outcome);
void resolve(String outcome, Map<String, Object> evidence);
void finish();
@Override void close();
}
@@ -10,6 +10,7 @@ import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -48,8 +49,17 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
}
}
private static final class Attempt {
final AbilityExecution execution; final LivingEntity target; boolean accepted;
Attempt(AbilityExecution execution, LivingEntity target) { this.execution = execution; this.target = target; }
final AbilityExecution execution; final LivingEntity target; final double requested;
final BigDecimal defined; final UUID chainId; final int ordinal;
final UUID previousId; final Map<String, Object> previousLocation;
boolean accepted;
Attempt(AbilityExecution execution, LivingEntity target, double requested, BigDecimal defined,
UUID chainId, int ordinal, LivingEntity previous) {
this.execution = execution; this.target = target; this.requested = requested;
this.defined = defined; this.chainId = chainId; this.ordinal = ordinal;
previousId = previous == null ? null : previous.getUniqueId();
previousLocation = previous == null ? Map.of() : locationData(previous.getLocation());
}
}
private volatile Settings settings;
@@ -103,27 +113,41 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
if (closed || !execution.stillCurrent()) return;
var captured = from(execution.definition()); var caster = execution.input().player();
var initial = selectInitial(execution.input(), captured.initialRange());
if (initial == null) return;
if (initial == null) { execution.resolve("miss", Map.of("reason", "initial_target_unavailable")); return; }
BigDecimal defined = new BigDecimal(initialDamage(captured.baseDamage(), execution.character().stats().get(Stat.INT)));
var visited = new HashSet<UUID>(); LivingEntity current = initial;
var visited = new HashSet<UUID>(); LivingEntity current = initial, previous = null;
var chainId = UUID.randomUUID(); int attempted = 0, accepted = 0;
int maximum = Math.min(64, 1 + captured.additionalTargets());
for (int strike = 0; strike < maximum && current != null && execution.stillCurrent(); strike++) {
for (int hop = 0; hop < maximum && current != null && execution.stillCurrent(); hop++) {
if (!visited.add(current.getUniqueId()) || !eligible(caster, current)) break;
strike(execution, caster, current, defined); visual(caster, current);
if (strike + 1 == maximum) break;
var attempt = strike(execution, caster, current, defined, chainId, hop + 1, previous);
attempted++; if (attempt.accepted) accepted++;
visual(caster, current);
if (hop + 1 == maximum) break;
defined = defined.multiply(captured.jumpMultiplier());
previous = current;
current = next(caster, current, visited, captured.jumpRange());
}
var outcome = new LinkedHashMap<String, Object>();
outcome.put("chain_id", chainId.toString());
outcome.put("initial_target_id", initial.getUniqueId().toString());
outcome.put("initial_target_location", locationData(initial.getLocation()));
outcome.put("attempted_hops", attempted); outcome.put("accepted_hops", accepted);
outcome.put("terminal_reason", !execution.stillCurrent() ? "source_interrupted"
: attempted == maximum ? "target_limit" : "no_eligible_next_target");
execution.resolve(!execution.stillCurrent() ? "interrupted" : accepted > 0 ? "hit" : "miss", outcome);
}
private void strike(AbilityExecution execution, Player caster, LivingEntity target, BigDecimal defined) {
private Attempt strike(AbilityExecution execution, Player caster, LivingEntity target,
BigDecimal defined, UUID chainId, int ordinal, LivingEntity previous) {
double amount = finite(definiteNonnegative(defined));
var origin = caster.getEyeLocation().clone();
var source = DamageSource.builder(DamageType.LIGHTNING_BOLT).withCausingEntity(caster)
.withDirectEntity(caster).withDamageLocation(origin).build();
var attempt = new Attempt(execution, target); damaging = attempt;
var attempt = new Attempt(execution, target, amount, defined, chainId, ordinal, previous); damaging = attempt;
try { execution.damage(target, amount, source); }
finally { if (damaging == attempt) damaging = null; }
return attempt;
}
private LivingEntity selectInitial(ControlInput input, double range) {
@@ -179,13 +203,42 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageEvent event) {
var attempt = damaging;
if (attempt == null || attempt.accepted || event.getEntity() != attempt.target
|| event.getCause() != EntityDamageEvent.DamageCause.LIGHTNING
if (!ownsAcceptedDamage(event) || attempt.accepted
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
attempt.accepted = true;
var evidence = new LinkedHashMap<String, Object>();
evidence.put("damage_kind", "owned_chain_strike");
evidence.put("chain_id", attempt.chainId.toString());
evidence.put("hop_ordinal", attempt.ordinal);
evidence.put("defined_amount", attempt.defined.stripTrailingZeros().toPlainString());
if (attempt.previousId != null) {
evidence.put("previous_target_id", attempt.previousId.toString());
evidence.put("previous_target_location", attempt.previousLocation);
}
attempt.execution.acceptedDamage(attempt.target, attempt.requested, event.getFinalDamage(),
attempt.execution.input().player(), evidence);
attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target, BigDecimal.valueOf(event.getFinalDamage()));
}
/** Exclude only this in-flight native hop from the generic damage observer. */
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
var attempt = damaging;
if (attempt == null || event.getEntity() != attempt.target
|| event.getCause() != EntityDamageEvent.DamageCause.LIGHTNING) return false;
var source = attempt.execution.input().player();
var causing = event.getDamageSource().getCausingEntity();
var direct = event.getDamageSource().getDirectEntity();
return (causing == null || causing == source) && (direct == null || direct == source);
}
private static Map<String, Object> locationData(Location location) {
var world = location == null ? null : location.getWorld();
if (world == null || world.getUID() == null || world.getName() == null)
return Map.of("location_status", "unavailable");
return Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
"x", location.getX(), "y", location.getY(), "z", location.getZ());
}
private void visual(Player caster, LivingEntity target) {
try {
var location = target.getLocation().clone().add(0, target.getHeight() / 2, 0);
@@ -18,9 +18,12 @@ import org.bukkit.event.entity.EntityDamageEvent;
/** Internal strongest-reduction stage shared by Fantasy Stick protection effects. */
public final class CombatMitigation implements Listener, AutoCloseable {
private final class Grant implements AutoCloseable {
final AbilityExecution execution; final Player player; final BigDecimal percent; final BooleanSupplier current; boolean closed;
Grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current) {
final AbilityExecution execution; final Player player; final BigDecimal percent; final BooleanSupplier current;
final java.util.function.BiConsumer<EntityDamageEvent, Double> acceptedObservation; boolean closed;
Grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current,
java.util.function.BiConsumer<EntityDamageEvent, Double> acceptedObservation) {
this.execution = execution; this.player = player; this.percent = percent; this.current = current;
this.acceptedObservation = acceptedObservation;
}
boolean valid() {
try { return !closed && execution.stillCurrent() && current.getAsBoolean(); }
@@ -42,10 +45,16 @@ public final class CombatMitigation implements Listener, AutoCloseable {
}
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current) {
return grant(execution, player, percent, current, null);
}
/** An owner may observe only the selected, positive native prevention after final cancellation. */
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current,
java.util.function.BiConsumer<EntityDamageEvent, Double> acceptedObservation) {
Objects.requireNonNull(execution); Objects.requireNonNull(player); Objects.requireNonNull(percent); Objects.requireNonNull(current);
if (closed || percent.signum() < 0 || percent.compareTo(BigDecimal.valueOf(100)) > 0)
throw new IllegalArgumentException("Invalid combat mitigation grant");
var grant = new Grant(execution, player, percent, current);
var grant = new Grant(execution, player, percent, current, acceptedObservation);
grants.computeIfAbsent(player.getUniqueId(), ignored -> new ArrayList<>()).add(grant);
return grant;
}
@@ -76,6 +85,9 @@ public final class CombatMitigation implements Listener, AutoCloseable {
if (prevention == null || event.isCancelled() || event.getFinalDamage() <= 0
|| !prevention.grant.valid()) return;
prevention.grant.execution.report(AbilityReport.Type.PREVENTION, event.getEntity(), prevention.amount);
if (prevention.grant.acceptedObservation != null) try {
prevention.grant.acceptedObservation.accept(event, prevention.amount.doubleValue());
} catch (RuntimeException ignored) { /* Audit cannot alter the accepted native hit. */ }
}
@Override public void close() {
@@ -56,6 +56,8 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
final BigInteger intelligence;
final Settings settings;
LivingEntity target;
double requestedDamage;
boolean accepted;
boolean finished;
State(Arrow projectile, AbilityExecution execution, Location origin, BigInteger intelligence, Settings settings) {
this.projectile = projectile; this.execution = execution; this.origin = origin;
@@ -123,8 +125,14 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
}
execution.own(state);
long rangeTicks = Math.max(1, (long) Math.ceil(current.range() / current.speedPerTick()));
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), execution::finish);
execution.later(current.lifetime(), execution::finish);
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), () -> {
execution.resolve("expired", Map.of("projectile_id", projectile.getUniqueId().toString(),
"limit", "range", "effective_settings", settingsData(current))); execution.finish();
});
execution.later(current.lifetime(), () -> {
execution.resolve("expired", Map.of("projectile_id", projectile.getUniqueId().toString(),
"limit", "lifetime", "effective_settings", settingsData(current))); execution.finish();
});
particles.world(Particle.FLAME, origin, 8, 0.15, 0.15, 0.15, 0.01);
}
@@ -141,14 +149,44 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
var source = DamageSource.builder(DamageType.FIREBALL).withCausingEntity(state.execution.input().player())
.withDirectEntity(state.projectile).withDamageLocation(state.projectile.getLocation()).build();
state.target = target;
state.requestedDamage = nativeDamage;
damaging = state;
try { state.execution.damage(target, nativeDamage, source); }
finally { if (damaging == state) damaging = null; }
} finally {
state.execution.finish();
String outcome = state.accepted ? "hit" : event.isCancelled() ? "cancelled" : "miss";
try {
if (outcome.equals("miss") && (closed || !state.execution.stillCurrent())) outcome = "interrupted";
var evidence = new java.util.LinkedHashMap<String, Object>();
evidence.put("projectile_id", state.projectile.getUniqueId().toString());
evidence.put("direct_entity_id", state.projectile.getUniqueId().toString());
evidence.put("effective_settings", settingsData(state.settings));
var impact = event.getEntity().getLocation();
if (impact != null && impact.getWorld() != null) evidence.put("target_location", locationData(impact));
if (event.getHitEntity() != null) evidence.put("target_entity_id", event.getHitEntity().getUniqueId().toString());
state.execution.resolve(outcome, evidence);
} catch (RuntimeException auditFailure) {
state.execution.resolve(outcome);
} finally {
state.execution.finish();
}
}
}
private static Map<String, Object> settingsData(Settings settings) {
return Map.of("base-damage", settings.baseDamage().toString(),
"cooldown-seconds", seconds(settings.cooldown()).toPlainString(),
"range", Double.toString(settings.range()),
"lifetime", seconds(settings.lifetime()).toPlainString(),
"projectile-speed", Double.toString(settings.speedPerTick()));
}
private static Map<String, Object> locationData(Location location) {
return Map.of("world_id", location.getWorld().getUID().toString(),
"world_name", location.getWorld().getName(), "x", location.getX(),
"y", location.getY(), "z", location.getZ());
}
private boolean inRange(State state, Location current) {
return current.getWorld() != null && state.origin.getWorld() != null
&& current.getWorld().equals(state.origin.getWorld())
@@ -162,16 +200,25 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
if (state != null && damaging != state) event.setCancelled(true);
}
/** One native application belongs to this projectile, including a fantasy-player target. */
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
var state = damaging;
if (state == null || event.getEntity() != state.target) return false;
var direct = event.getDamageSource().getDirectEntity();
if (direct != state.projectile && (!(event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|| byEntity.getDamager() != state.projectile)) return false;
var causing = event.getDamageSource().getCausingEntity();
return causing == null || causing == state.execution.input().player();
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageEvent event) {
var state = damaging;
if (state == null || event.getEntity() != state.target || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage())) return;
var direct = event.getDamageSource().getDirectEntity();
if (direct != state.projectile && (!(event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|| byEntity.getDamager() != state.projectile)) return;
var causing = event.getDamageSource().getCausingEntity();
if (causing != null && causing != state.execution.input().player()) return;
if (state == null || state.accepted || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage()) || !ownsAcceptedDamage(event)) return;
state.accepted = true;
state.execution.acceptedDamage(state.target, state.requestedDamage, event.getFinalDamage(),
state.projectile, Map.of("damage_kind", "owned_direct_projectile"));
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
}
@@ -182,7 +229,9 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
@Override public void close() {
if (closed) return;
closed = true;
for (var state : List.copyOf(projectiles.values())) state.execution.finish();
for (var state : List.copyOf(projectiles.values())) {
state.execution.resolve("interrupted"); state.execution.finish();
}
projectiles.clear(); damaging = null;
}
}
@@ -50,36 +50,61 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
private static final class SlowLease implements AutoCloseable {
final LivingEntity target; final PotionEffect previous; final PotionEffect applied;
final AbilityExecution execution; UUID application;
final long started = System.nanoTime(); boolean owned; boolean closed;
SlowLease(LivingEntity target, PotionEffect applied) {
SlowLease(LivingEntity target, PotionEffect applied, AbilityExecution execution) {
this.target = target; previous = target.getPotionEffect(PotionEffectType.SLOWNESS); this.applied = applied;
this.execution = execution;
}
boolean apply() { owned = target.addPotionEffect(applied); return owned; }
@Override public void close() {
boolean apply() {
owned = target.addPotionEffect(applied);
if (owned) try {
var evidence = new LinkedHashMap<String, Object>();
evidence.put("amplifier", applied.getAmplifier());
evidence.put("duration_ticks", applied.getDuration());
evidence.put("deadline", java.time.Instant.now().plusMillis(applied.getDuration() * 50L).toString());
if (previous != null) {
evidence.put("replaced_amplifier", previous.getAmplifier());
evidence.put("replaced_duration_ticks", previous.getDuration());
}
application = execution.effectApplied("frost_nova_slowness", target, evidence);
} catch (RuntimeException ignored) { /* Auditing cannot refuse a native effect. */ }
return owned;
}
@Override public void close() { close("interrupted"); }
void close(String reason) {
if (closed) return; closed = true; if (!owned) return;
var current = target.getPotionEffect(PotionEffectType.SLOWNESS);
long elapsed = Math.max(0, (System.nanoTime() - started) / 50_000_000L);
int expected = (int) Math.max(0, (long) applied.getDuration() - elapsed);
if (current == null || current.getAmplifier() != applied.getAmplifier()
|| current.getDuration() > expected + 2) return;
|| current.getDuration() > expected + 2) {
if (application != null) execution.effectEnded(application, "superseded");
return;
}
target.removePotionEffect(PotionEffectType.SLOWNESS);
if (previous != null) {
int remaining = (int) Math.max(0, (long) previous.getDuration() - elapsed);
if (remaining > 0) target.addPotionEffect(previous.withDuration(remaining));
}
if (application != null) execution.effectEnded(application, reason);
}
}
private final class LeaseGroup implements AutoCloseable {
final AbilityExecution execution; final List<SlowLease> leases = new ArrayList<>(); boolean closed;
final AbilityExecution execution; final List<SlowLease> leases = new ArrayList<>();
String endReason = "interrupted"; boolean closed;
LeaseGroup(AbilityExecution execution) { this.execution = execution; }
@Override public void close() {
if (closed) return; closed = true; active.remove(execution, this);
for (var lease : List.copyOf(leases)) lease.close(); leases.clear();
for (var lease : List.copyOf(leases)) lease.close(endReason); leases.clear();
}
}
private static final class Attempt {
final AbilityExecution execution; final LivingEntity target; boolean accepted;
Attempt(AbilityExecution execution, LivingEntity target) { this.execution = execution; this.target = target; }
final AbilityExecution execution; final LivingEntity target; final double requested; final int ordinal;
boolean accepted;
Attempt(AbilityExecution execution, LivingEntity target, double requested, int ordinal) {
this.execution = execution; this.target = target; this.requested = requested; this.ordinal = ordinal;
}
}
private volatile Settings settings;
@@ -133,28 +158,39 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
var group = new LeaseGroup(execution); active.put(execution, group); execution.own(group);
BigInteger exact = damage(captured.baseDamage(), execution.character().stats().get(Stat.INT));
double amount = exact.bitLength() > 1023 ? Double.MAX_VALUE : exact.doubleValue();
int affected = 0;
for (var target : candidates(source, origin, captured.radius())) {
int affected = 0, accepted = 0;
var candidates = candidates(source, origin, captured.radius());
for (var target : candidates) {
if (affected == 64 || !execution.stillCurrent()) break;
if (!eligible(execution, target, origin, captured.radius())) continue;
var sourceDamage = DamageSource.builder(DamageType.FREEZE).withCausingEntity(source)
.withDirectEntity(source).withDamageLocation(origin).build();
var attempt = new Attempt(execution, target); damaging = attempt;
var attempt = new Attempt(execution, target, amount, affected + 1); damaging = attempt;
try { execution.damage(target, amount, sourceDamage); }
finally { if (damaging == attempt) damaging = null; }
affected++;
if (!attempt.accepted || !execution.stillCurrent() || !eligible(execution, target, origin, captured.radius())) continue;
if (!attempt.accepted) continue;
accepted++;
if (!execution.stillCurrent() || !eligible(execution, target, origin, captured.radius())) continue;
int ticks = Math.toIntExact(Math.max(1,
(captured.slownessDuration().toNanos() + 49_999_999L) / 50_000_000L));
var lease = new SlowLease(target, new PotionEffect(PotionEffectType.SLOWNESS, ticks,
captured.slownessAmplifier(), false, true, true));
captured.slownessAmplifier(), false, true, true), execution);
if (lease.apply()) group.leases.add(lease);
}
try { particles.world(Particle.SNOWFLAKE, origin, 32,
captured.radius() / 2, captured.radius() / 2, captured.radius() / 2, 0.01); }
catch (RuntimeException ignored) { }
var result = new LinkedHashMap<String, Object>();
result.put("candidate_count", candidates.size()); result.put("attempt_count", affected);
result.put("accepted_count", accepted); result.put("slowness_count", group.leases.size());
result.put("radius", captured.radius()); result.put("requested_amount", amount);
result.put("burst_location", locationData(origin));
execution.resolve(accepted > 0 ? "hit" : "miss", result);
if (group.leases.isEmpty()) execution.finish();
else execution.later(captured.slownessDuration(), execution::finish);
else execution.later(captured.slownessDuration(), () -> {
group.endReason = "expired"; execution.finish();
});
}
private List<LivingEntity> candidates(Player source, Location origin, double radius) {
@@ -188,13 +224,33 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageEvent event) {
var attempt = damaging;
if (attempt == null || attempt.accepted || event.getEntity() != attempt.target
|| event.getCause() != EntityDamageEvent.DamageCause.FREEZE
if (!ownsAcceptedDamage(event) || attempt.accepted
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
attempt.accepted = true;
attempt.execution.acceptedDamage(attempt.target, attempt.requested, event.getFinalDamage(),
attempt.execution.input().player(), Map.of("damage_kind", "owned_area_burst", "ordinal", attempt.ordinal));
attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target, BigDecimal.valueOf(event.getFinalDamage()));
}
/** The per-target native hit belongs to this cast, not the generic damage observer. */
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
var attempt = damaging;
if (attempt == null || event.getEntity() != attempt.target
|| event.getCause() != EntityDamageEvent.DamageCause.FREEZE) return false;
var source = attempt.execution.input().player();
var causing = event.getDamageSource().getCausingEntity();
var direct = event.getDamageSource().getDirectEntity();
return (causing == null || causing == source) && (direct == null || direct == source);
}
private static Map<String, Object> locationData(Location origin) {
var world = origin.getWorld();
if (world == null || world.getUID() == null || world.getName() == null)
return Map.of("location_status", "unavailable");
return Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
"x", origin.getX(), "y", origin.getY(), "z", origin.getZ());
}
private static boolean unobstructed(Location origin, LivingEntity target) {
var world = origin.getWorld(); if (world == null) return false;
var location = target.getLocation(); if (location.getWorld() != world) return false;
@@ -56,6 +56,8 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
final Player ally;
final EquipmentSessions.Active aided;
final Settings settings;
UUID application;
String endReason = "interrupted";
boolean closed;
State(AbilityExecution execution, Player guardian, Player ally,
EquipmentSessions.Active aided, Settings settings) {
@@ -67,14 +69,16 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
closed = true;
byGuardian.remove(guardian.getUniqueId(), this);
byAlly.remove(ally.getUniqueId(), this);
if (application != null) execution.effectEnded(application, endReason);
}
}
private static final class NativeTransfer {
final Player guardian; final Entity attacker; final BigDecimal requested; final BigDecimal available;
final State owner; final Player guardian; final Entity attacker; final BigDecimal requested; final BigDecimal available;
BigDecimal accepted = BigDecimal.ZERO;
NativeTransfer(Player guardian, Entity attacker, BigDecimal requested, BigDecimal available) {
this.guardian = guardian; this.attacker = attacker; this.requested = requested; this.available = available;
NativeTransfer(State owner, Player guardian, Entity attacker, BigDecimal requested, BigDecimal available) {
this.owner = owner; this.guardian = guardian; this.attacker = attacker;
this.requested = requested; this.available = available;
}
}
@@ -84,11 +88,14 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
private final EquipmentSessions sessions;
private final PartyRoster parties;
private final Damage damage;
private final boolean nativeDamageMode;
private final Consumer<Support> supports;
private final Map<UUID, State> byGuardian = new java.util.HashMap<>();
private final Map<UUID, State> byAlly = new java.util.HashMap<>();
private int transferring;
private NativeTransfer nativeTransfer;
private record Reduction(State state, Entity attacker, double incoming, double accepted) { }
private final Map<EntityDamageByEntityEvent, Reduction> pendingReductions = new java.util.IdentityHashMap<>();
private org.bukkit.scheduler.BukkitTask sweepTask;
private boolean closed;
@@ -102,7 +109,8 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
this.settings = Objects.requireNonNull(settings);
this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties);
this.damage = damage == null ? this::nativeDamage : damage;
this.nativeDamageMode = damage == null;
this.damage = nativeDamageMode ? this::nativeDamage : damage;
this.supports = Objects.requireNonNull(supports);
}
@@ -153,7 +161,14 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
}
var state = new State(execution, guardian, ally, aided, captured);
byGuardian.put(guardian.getUniqueId(), state); byAlly.put(ally.getUniqueId(), state);
execution.own(state); execution.later(captured.duration(), execution::finish);
execution.own(state);
state.application = execution.effectApplied("guardian_bond", ally,
Map.of("redirect_percent", captured.redirectPercent().toPlainString(),
"range", Double.toString(captured.range()),
"duration_seconds", seconds(captured.duration())));
execution.later(captured.duration(), () -> {
state.endReason = "expired"; execution.resolve("expired"); execution.finish();
});
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
@@ -184,8 +199,30 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
double remaining = BigDecimal.valueOf(incoming).subtract(accepted).max(BigDecimal.ZERO).doubleValue();
if (!Double.isFinite(remaining)) return;
event.setDamage(remaining);
state.execution.report(AbilityReport.Type.PREVENTION, ally, accepted);
supports.accept(new Support(attacker.getUniqueId(), state.execution.input().session().character(), state.aided, accepted));
pendingReductions.put(event, new Reduction(state, attacker, incoming, accepted.doubleValue()));
if (!nativeDamageMode) {
// Injected test adapters return their final accepted result directly.
state.execution.report(AbilityReport.Type.PREVENTION, ally, accepted);
supports.accept(new Support(attacker.getUniqueId(), state.execution.input().session().character(), state.aided, accepted));
}
}
/** Only an uncancelled original hit can claim actual prevention. */
@EventHandler(priority = EventPriority.MONITOR)
public void acceptedPrevention(EntityDamageByEntityEvent event) {
var reduction = pendingReductions.remove(event);
if (reduction == null || event.isCancelled() || !Double.isFinite(event.getFinalDamage())) return;
double actual = Math.min(reduction.accepted(), Math.max(0, reduction.incoming() - event.getFinalDamage()));
if (actual <= 0) return;
var state = reduction.state();
state.execution.acceptedPrevention(state.aided, state.ally, reduction.attacker(),
reduction.incoming(), actual, event.getFinalDamage(), state.application);
if (nativeDamageMode) {
var amount = BigDecimal.valueOf(actual);
state.execution.report(AbilityReport.Type.PREVENTION, state.ally, amount);
supports.accept(new Support(reduction.attacker().getUniqueId(),
state.execution.input().session().character(), state.aided, amount));
}
}
/** Capture accepted marked damage before synchronous fantasy-defeat restoration can replace native health. */
@@ -193,7 +230,8 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
Objects.requireNonNull(guardian); Objects.requireNonNull(attacker); Objects.requireNonNull(requested);
double amount = requested.doubleValue(); double health = effectiveHealth(guardian);
if (!Double.isFinite(amount) || amount <= 0 || !Double.isFinite(health) || health <= 0) return BigDecimal.ZERO;
var transfer = new NativeTransfer(guardian, attacker, requested, BigDecimal.valueOf(health));
var transfer = new NativeTransfer(byGuardian.get(guardian.getUniqueId()), guardian, attacker,
requested, BigDecimal.valueOf(health));
nativeTransfer = transfer;
try { guardian.damage(amount, attacker); }
finally { nativeTransfer = null; }
@@ -206,6 +244,17 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
if (transfer == null || event.getEntity() != transfer.guardian || attacker(event) != transfer.attacker
|| event.isCancelled() || !Double.isFinite(event.getFinalDamage()) || event.getFinalDamage() <= 0) return;
transfer.accepted = BigDecimal.valueOf(event.getFinalDamage()).min(transfer.requested).min(transfer.available);
if (transfer.owner != null && transfer.accepted.signum() > 0)
transfer.owner.execution.acceptedDamage(transfer.guardian, transfer.requested.doubleValue(),
transfer.accepted.doubleValue(), transfer.attacker,
Map.of("damage_kind", "guardian_bond_redirection"));
}
/** The guardian's accepted native hit has one richer bond-owned damage record. */
public boolean ownsAcceptedRedirect(org.bukkit.event.entity.EntityDamageEvent event) {
var transfer = nativeTransfer;
return transfer != null && event instanceof EntityDamageByEntityEvent hit
&& hit.getEntity() == transfer.guardian && attacker(hit) == transfer.attacker;
}
/** True only during synchronous dispatch of Guardian Bond's marked redirected damage. */
@@ -230,7 +279,8 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
}
private void breakBond(State state) {
state.close(); state.execution.finish();
state.endReason = "eligibility_changed";
state.close(); state.execution.resolve("interrupted"); state.execution.finish();
}
private static boolean eligible(Player guardian, Player ally, double range) {
@@ -268,7 +318,9 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
@Override public void close() {
if (closed) return; closed = true;
if (sweepTask != null) sweepTask.cancel();
for (var state : List.copyOf(byGuardian.values())) state.execution.finish();
byGuardian.clear(); byAlly.clear();
for (var state : List.copyOf(byGuardian.values())) {
state.endReason = "shutdown"; state.execution.resolve("interrupted"); state.execution.finish();
}
byGuardian.clear(); byAlly.clear(); pendingReductions.clear();
}
}
@@ -101,25 +101,31 @@ public final class MendingLight implements AbilityEffect {
if (!execution.stillCurrent()) return;
var configured = from(execution.definition());
var target = target(execution.input(), configured.range());
if (target == null) { execution.finish(); return; }
if (target == null) { execution.resolve("miss"); execution.finish(); return; }
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) { execution.finish(); return; }
if (attribute == null) { execution.resolve("miss"); execution.finish(); return; }
double current = target.player().getHealth(); double maximum = attribute.getValue(); double missing = maximum - current;
if (!Double.isFinite(current) || !Double.isFinite(maximum) || missing <= 0) { execution.finish(); return; }
if (!Double.isFinite(current) || !Double.isFinite(maximum) || missing <= 0) {
execution.resolve("miss"); execution.finish(); return;
}
BigInteger exact = healing(configured.baseHealing(), execution.character().stats().get(Stat.WIS));
double proposed = exact.bitLength() > 1023 ? missing : Math.min(missing, exact.doubleValue());
if (!Double.isFinite(proposed) || proposed <= 0) { execution.finish(); return; }
if (!Double.isFinite(proposed) || proposed <= 0) {
execution.resolve("miss"); execution.finish(); return;
}
var event = new EntityRegainHealthEvent(target.player(), proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
healingEvents.accept(event);
double accepted = Math.min(proposed, Math.min(missing, event.getAmount()));
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) { execution.finish(); return; }
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) {
execution.resolve(event.isCancelled() ? "cancelled" : "miss"); execution.finish(); return;
}
var expected = target.record().revision();
BigDecimal persistedHealth = BigDecimal.valueOf(current + accepted);
BigDecimal limit = nativeLimit.get();
characters.saveHealth(target.record().id(), expected, persistedHealth, limit).whenComplete((saved, failure) -> {
try {
nextTick.accept(() -> complete(execution, target, expected, accepted, saved, failure));
} catch (RuntimeException rejected) { execution.finish(); }
} catch (RuntimeException rejected) { execution.resolve("failed"); execution.finish(); }
});
}
@@ -127,20 +133,34 @@ public final class MendingLight implements AbilityEffect {
java.util.Optional<CharacterRecord> saved, Throwable failure) {
try {
if (failure != null || saved == null || saved.isEmpty()) {
execution.resolve("failed");
execution.input().player().sendMessage("Mending Light health could not be saved; no healing was applied.");
return;
}
if (!execution.stillCurrent() || !current(execution.input().player(), target, from(execution.definition()).range())) return;
if (!execution.stillCurrent() || !current(execution.input().player(), target, from(execution.definition()).range())) {
execution.resolve("interrupted"); return;
}
var persisted = saved.orElseThrow();
if (!sessions.acknowledgeProgress(target.active(), expected, persisted)) return;
if (!sessions.acknowledgeProgress(target.active(), expected, persisted)) {
execution.resolve("interrupted"); return;
}
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) return;
double current = target.player().getHealth(); double maximum = attribute.getValue();
double desired = Math.min(maximum, persisted.health().doubleValue());
double restored = Math.min(accepted, desired - current);
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) return;
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) {
execution.resolve("miss"); return;
}
target.player().setHealth(Math.min(maximum, current + restored));
var amount = BigDecimal.valueOf(restored).stripTrailingZeros();
double after = target.player().getHealth();
double actual = after - current;
if (!Double.isFinite(actual) || actual <= 0) { execution.resolve("miss"); return; }
execution.acceptedHeal(target.active(), target.player(), persisted, accepted, actual, current, after, maximum);
execution.resolve("healed", Map.of("target_player_id", target.player().getUniqueId().toString(),
"target_character_id", target.active().character().toString(),
"actual_amount", BigDecimal.valueOf(actual).stripTrailingZeros().toPlainString()));
var amount = BigDecimal.valueOf(actual).stripTrailingZeros();
supports.accept(new Support(execution.input().session().character(), target.active(), amount));
execution.report(AbilityReport.Type.HEALING, target.player(), amount);
} finally { execution.finish(); }
@@ -39,14 +39,54 @@ public final class Meteor implements AbilityEffect, Listener, AutoCloseable {
Duration.ofSeconds(2),5,4);}
}
private record Ground(Block block,Location origin){ }
private static final class Attempt {final AbilityExecution execution;final LivingEntity target;boolean accepted;Attempt(AbilityExecution e,LivingEntity t){execution=e;target=t;}}
private static final class Attempt {
final AbilityExecution execution;final LivingEntity target;final double requested;final int ordinal;
boolean accepted;
Attempt(AbilityExecution execution,LivingEntity target,double requested,int ordinal){
this.execution=execution;this.target=target;this.requested=requested;this.ordinal=ordinal;
}
}
private final class Warning implements AutoCloseable {
final AbilityExecution execution;final Player source;final Ground ground;final Settings captured;final BigInteger intelligence;
final int maximumTicks;int elapsed;boolean impacted;boolean closed;
Warning(AbilityExecution execution,Ground ground,Settings captured){this.execution=execution;source=execution.input().player();this.ground=ground;this.captured=captured;intelligence=execution.character().stats().get(Stat.INT);maximumTicks=(int)Math.max(1,(captured.warning().toNanos()+49_999_999L)/50_000_000L);}
void start(){warnings.put(execution,this);execution.own(this);warningVisual();execution.later(Duration.ofMillis(50),this::step);}
void step(){if(closed||impacted)return;if(!execution.stillCurrent()){execution.finish();return;}elapsed++;if(elapsed>=maximumTicks){impact();execution.finish();return;}warningVisual();execution.later(Duration.ofMillis(50),this::step);}
void impact(){if(closed||impacted)return;impacted=true;if(!groundCurrent()||!execution.stillCurrent())return;impactVisual();double amount=finite(damage(captured.baseDamage(),intelligence));int affected=0;for(var target:candidates(ground.origin(),captured.radius(),captured.verticalRange())){if(affected==64||!execution.stillCurrent())break;if(!eligible(execution,target,ground.origin(),captured.radius(),captured.verticalRange()))continue;var sourceDamage=DamageSource.builder(DamageType.MAGIC).withCausingEntity(source).withDirectEntity(source).withDamageLocation(ground.origin()).build();var attempt=new Attempt(execution,target);damaging=attempt;try{execution.damage(target,amount,sourceDamage);}finally{if(damaging==attempt)damaging=null;}affected++;}}
void step(){
if(closed||impacted)return;
if(!execution.stillCurrent()){
execution.resolve("interrupted",Map.of("stage","warning","warning_ticks_elapsed",elapsed));
execution.finish();return;
}
elapsed++;
if(elapsed>=maximumTicks){impact();execution.finish();return;}
warningVisual();execution.later(Duration.ofMillis(50),this::step);
}
void impact(){
if(closed||impacted)return;impacted=true;
if(!groundCurrent()||!execution.stillCurrent()){
execution.resolve("interrupted",Map.of("stage","warning","reason","ground_or_source_unavailable"));
return;
}
impactVisual();double amount=finite(damage(captured.baseDamage(),intelligence));
int attempted=0,accepted=0;var candidates=candidates(ground.origin(),captured.radius(),captured.verticalRange());
for(var target:candidates){
if(attempted==64||!execution.stillCurrent())break;
if(!eligible(execution,target,ground.origin(),captured.radius(),captured.verticalRange()))continue;
var sourceDamage=DamageSource.builder(DamageType.MAGIC).withCausingEntity(source)
.withDirectEntity(source).withDamageLocation(ground.origin()).build();
var attempt=new Attempt(execution,target,amount,attempted+1);damaging=attempt;
try{execution.damage(target,amount,sourceDamage);}
finally{if(damaging==attempt)damaging=null;}
attempted++;if(attempt.accepted)accepted++;
}
var evidence=new LinkedHashMap<String,Object>();
evidence.put("stage","impact");evidence.put("warning_ticks_elapsed",elapsed);
evidence.put("impact_location",locationData(ground.origin()));
evidence.put("candidate_count",candidates.size());evidence.put("attempted_count",attempted);
evidence.put("accepted_count",accepted);evidence.put("requested_amount",amount);
evidence.put("terminal_reason",!execution.stillCurrent()?"source_interrupted":accepted>0?"accepted_hits":"no_accepted_targets");
execution.resolve(!execution.stillCurrent()?"interrupted":accepted>0?"hit":"miss",evidence);
}
boolean groundCurrent(){try{return ground.origin().getWorld()!=null&&ground.block().getWorld()==ground.origin().getWorld()&&ground.block().getType().isSolid();}catch(RuntimeException invalid){return false;}}
void warningVisual(){try{var dust=new Particle.DustOptions(Color.fromRGB(255,90,20),1.6f);particles.world(Particle.DUST,ground.origin().clone().add(0,.15,0),24,captured.radius()/2,.05,captured.radius()/2,0,dust);}catch(RuntimeException ignored){}}
void impactVisual(){try{var world=ground.origin().getWorld();particles.world(Particle.EXPLOSION_EMITTER,ground.origin(),2,0,0,0,0);particles.world(Particle.FLAME,ground.origin(),48,captured.radius()/2,1,captured.radius()/2,.04);world.playSound(ground.origin(),Sound.ENTITY_GENERIC_EXPLODE,1f,.7f);}catch(RuntimeException ignored){}}
@@ -66,11 +106,39 @@ public final class Meteor implements AbilityEffect, Listener, AutoCloseable {
public static BigInteger damage(BigInteger intelligence){return damage(BigInteger.valueOf(14),intelligence);}
static BigInteger damage(BigInteger base,BigInteger intelligence){Objects.requireNonNull(base);Objects.requireNonNull(intelligence);if(base.signum()<0||intelligence.signum()<0)throw new IllegalArgumentException("Invalid Meteor damage input");var delta=intelligence.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 boolean accepts(ControlInput input){return !closed&&input.target()==null&&ground(input.player(),settings.targetRange())!=null;}
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent())return;var captured=from(execution.definition());var target=ground(execution.input().player(),captured.targetRange());if(target==null){execution.finish();return;}new Warning(execution,target,captured).start();}
@Override public void activate(AbilityExecution execution){
if(closed||!execution.stillCurrent())return;
var captured=from(execution.definition());var target=ground(execution.input().player(),captured.targetRange());
if(target==null){execution.resolve("miss",Map.of("stage","targeting","reason","ground_unavailable"));execution.finish();return;}
new Warning(execution,target,captured).start();
}
private static Ground ground(Player player,double range){try{var eye=player.getEyeLocation();var hit=player.getWorld().rayTraceBlocks(eye,eye.getDirection(),range,FluidCollisionMode.NEVER,true);if(hit==null||hit.getHitBlock()==null||hit.getHitBlockFace()!=BlockFace.UP||!hit.getHitBlock().getType().isSolid())return null;var block=hit.getHitBlock();return new Ground(block,block.getLocation().add(.5,1,.5));}catch(RuntimeException invalid){return null;}}
private List<LivingEntity> candidates(Location origin,double radius,double vertical){List<Entity> nearby;try{nearby=new ArrayList<>(origin.getWorld().getNearbyEntities(origin,radius,vertical,radius));}catch(RuntimeException unavailable){return List.of();}var unique=new LinkedHashMap<UUID,LivingEntity>();int inspected=0;for(var entity:nearby){if(++inspected>256)break;if(entity instanceof LivingEntity living)unique.putIfAbsent(living.getUniqueId(),living);}var result=new ArrayList<>(unique.values());result.sort(Comparator.comparingDouble((LivingEntity target)->distance(origin,target)).thenComparing(LivingEntity::getUniqueId));return result;}
private boolean eligible(AbilityExecution execution,LivingEntity target,Location origin,double radius,double vertical){var source=execution.input().player();if(target==source||!inside(origin,target,radius,vertical)||!unobstructed(origin,target))return false;if(!(target instanceof Player player))return true;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return false;var sourceParty=parties.partyOf(source.getUniqueId()).orElse(null);var targetParty=parties.partyOf(player.getUniqueId()).orElse(null);return sourceParty==null||targetParty==null||!sourceParty.id().equals(targetParty.id());}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void acceptedDamage(EntityDamageEvent event){var attempt=damaging;if(attempt==null||attempt.accepted||event.isCancelled()||event.getEntity()!=attempt.target||event.getCause()!=EntityDamageEvent.DamageCause.MAGIC||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;attempt.accepted=true;attempt.execution.report(AbilityReport.Type.DAMAGE,attempt.target,BigDecimal.valueOf(event.getFinalDamage()));}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)
public void acceptedDamage(EntityDamageEvent event){
var attempt=damaging;
if(!ownsAcceptedDamage(event)||attempt.accepted||event.isCancelled()
||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;
attempt.accepted=true;
attempt.execution.acceptedDamage(attempt.target,attempt.requested,event.getFinalDamage(),
attempt.execution.input().player(),Map.of("damage_kind","owned_meteor_impact","ordinal",attempt.ordinal));
attempt.execution.report(AbilityReport.Type.DAMAGE,attempt.target,BigDecimal.valueOf(event.getFinalDamage()));
}
/** Exclude only this accepted in-flight impact from the generic native damage observer. */
public boolean ownsAcceptedDamage(EntityDamageEvent event){
var attempt=damaging;
if(attempt==null||event.getEntity()!=attempt.target||event.getCause()!=EntityDamageEvent.DamageCause.MAGIC)return false;
var caster=attempt.execution.input().player();
var causing=event.getDamageSource().getCausingEntity();var direct=event.getDamageSource().getDirectEntity();
return (causing==null||causing==caster)&&(direct==null||direct==caster);
}
private static Map<String,Object> locationData(Location location){
var world=location==null?null:location.getWorld();
if(world==null||world.getUID()==null||world.getName()==null)return Map.of("location_status","unavailable");
return Map.of("world_id",world.getUID().toString(),"world_name",world.getName(),
"x",location.getX(),"y",location.getY(),"z",location.getZ());
}
private static boolean inside(Location origin,LivingEntity target,double radius,double vertical){try{var at=target.getLocation();if(origin.getWorld()!=at.getWorld())return false;double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz<=radius*radius&&Math.abs(at.getY()-origin.getY())<=vertical;}catch(RuntimeException invalid){return false;}}
private static boolean unobstructed(Location origin,LivingEntity target){try{var world=origin.getWorld();var at=target.getLocation();if(world==null||at.getWorld()!=world)return false;var bounds=target.getBoundingBox();var center=bounds==null?at.toVector().add(new org.bukkit.util.Vector(0,target.getHeight()/2,0)):bounds.getCenter();var offset=center.subtract(origin.toVector());double distance=offset.length();if(!Double.isFinite(distance)||distance==0)return distance==0;return world.rayTraceBlocks(origin,offset.multiply(1/distance),distance,FluidCollisionMode.NEVER,true)==null;}catch(RuntimeException invalid){return false;}}
private static double distance(Location origin,LivingEntity target){try{var at=target.getLocation();if(origin.getWorld()!=at.getWorld())return Double.POSITIVE_INFINITY;double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz;}catch(RuntimeException invalid){return Double.POSITIVE_INFINITY;}}
@@ -41,7 +41,7 @@ public final class OutgoingDamageBonus implements Listener, AutoCloseable {
if (owned != null) { owned.remove(this); if (owned.isEmpty()) grants.remove(recipient.getUniqueId()); }
}
}
private record Used(Grant grant, Entity target, BigDecimal amount) { }
private record Used(Grant grant, Entity target, BigDecimal amount, double base) { }
private final EquipmentSessions sessions;
private final PartyRoster parties;
@@ -82,20 +82,26 @@ public final class OutgoingDamageBonus implements Listener, AutoCloseable {
BigDecimal added = BigDecimal.valueOf(original).multiply(strongest.percent.movePointLeft(2));
double increased = BigDecimal.valueOf(original).add(added).doubleValue();
if (!Double.isFinite(increased) || increased < 0 || added.signum() <= 0) return;
event.setDamage(increased); pending.put(event, new Used(strongest, event.getEntity(), added));
event.setDamage(increased); pending.put(event, new Used(strongest, event.getEntity(), added, original));
}
@EventHandler(priority = EventPriority.MONITOR)
public void accepted(EntityDamageEvent event) {
var used = pending.remove(event);
if (used == null || event.isCancelled() || event.getFinalDamage() <= 0 || !used.grant.valid()) return;
used.grant.execution.report(AbilityReport.Type.BOOST_USED, used.target, used.amount);
if (used == null || event.isCancelled() || !Double.isFinite(event.getFinalDamage())
|| event.getFinalDamage() <= 0 || !used.grant.valid()) return;
double effective = Math.min(used.amount.doubleValue(), event.getFinalDamage());
if (!Double.isFinite(effective) || effective <= 0) return;
var aided = sessions.active(used.grant.recipient.getUniqueId()).filter(sessions::current).orElse(null);
used.grant.execution.acceptedBoost(aided, used.grant.recipient, used.target,
used.base, effective, event.getFinalDamage(), used.grant.percent);
var actual = BigDecimal.valueOf(effective);
used.grant.execution.report(AbilityReport.Type.BOOST_USED, used.target, actual);
if (aided == null || aided.character().equals(used.grant.execution.input().session().character())) return;
var sourceParty = parties.partyOf(used.grant.execution.input().player().getUniqueId()).orElse(null);
var aidedParty = parties.partyOf(used.grant.recipient.getUniqueId()).orElse(null);
if (sourceParty == null || aidedParty == null || !sourceParty.id().equals(aidedParty.id())) return;
supports.accept(new Support(used.target.getUniqueId(), used.grant.execution.input().session().character(), aided, used.amount));
supports.accept(new Support(used.target.getUniqueId(), used.grant.execution.input().session().character(), aided, actual));
}
private static Entity attacker(EntityDamageByEntityEvent event) {
@@ -72,16 +72,22 @@ public final class Purify implements AbilityEffect {
@Override public void activate(AbilityExecution execution) {
if (!execution.stillCurrent()) return;
var configured = from(execution.definition()); var target = target(execution.input(), configured.range());
if (target == null) return;
int removed = 0;
if (target == null) { execution.resolve("miss"); return; }
var removed = new java.util.ArrayList<String>();
int attempted = 0;
for (var type : HARMFUL) {
if (!execution.stillCurrent() || !current(execution.input(), target, configured.range())) break;
if (target.player().getPotionEffect(type) == null) continue;
attempted++;
target.player().removePotionEffect(type);
if (target.player().getPotionEffect(type) == null) removed++;
if (target.player().getPotionEffect(type) == null)
removed.add(type.getKey().getKey().toUpperCase(java.util.Locale.ROOT));
}
if (removed == 0) return;
var amount = BigDecimal.valueOf(removed);
if (removed.isEmpty()) { execution.resolve(attempted == 0 ? "miss" : "cancelled"); return; }
execution.acceptedCleanse(target.active(), target.player(), List.copyOf(removed));
execution.resolve("cleansed", Map.of("target_player_id", target.player().getUniqueId().toString(),
"removed_effects", List.copyOf(removed)));
var amount = BigDecimal.valueOf(removed.size());
execution.report(AbilityReport.Type.CLEANSING, target.player(), amount);
if (target.player() != execution.input().player()
&& execution.stillCurrent() && current(execution.input(), target, configured.range())) {
@@ -40,7 +40,8 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
private final class Area implements AutoCloseable {
final long sequence; final AbilityExecution execution; final Player source; final Settings settings;
final Map<UUID, Benefit> recipients = new java.util.HashMap<>(); boolean closed;
final Map<UUID, Benefit> recipients = new java.util.HashMap<>();
UUID application; String endReason = "interrupted"; boolean closed;
Area(AbilityExecution execution, Settings settings) {
sequence = ++sequences; this.execution = execution; source = execution.input().player(); this.settings = settings;
}
@@ -86,19 +87,33 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
@Override public void close() {
if (closed) return; closed = true; areas.remove(sequence, this);
for (var benefit : List.copyOf(recipients.values())) benefit.close(); recipients.clear();
if (application != null) execution.effectEnded(application, endReason);
}
}
private final class Benefit implements AutoCloseable {
final Area area; final Player player; final AutoCloseable speed; final AutoCloseable bonus; boolean closed;
final Area area; final Player player; final AutoCloseable speed; final AutoCloseable bonus;
final UUID application; boolean closed;
Benefit(Area area, Player player) {
this.area = area; this.player = player;
speed = speed(player, area.settings.speedAmplifier());
bonus = bonuses.grant(area.execution, player, area.settings.damageBonusPercent(), () -> !closed && area.eligible(player));
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
var evidence = new LinkedHashMap<String, Object>();
evidence.put("speed_amplifier", area.settings.speedAmplifier());
evidence.put("damage_bonus_percent", area.settings.damageBonusPercent().toPlainString());
evidence.put("speed_status", "owned_or_superseded_by_existing_speed");
if (active != null) {
evidence.put("recipient_character_id", active.character().toString());
evidence.put("recipient_session_id", active.session().toString());
}
application = area.execution.effectApplied("rallying_cry_benefit", player, evidence);
}
@Override public void close() {
if (closed) return; closed = true; area.recipients.remove(player.getUniqueId(), this);
closeResource(bonus); closeResource(speed);
if (application != null) area.execution.effectEnded(application,
area.closed ? area.endReason : "recipient_eligibility_changed");
}
}
@@ -187,14 +202,23 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var area = new Area(execution, from(execution.definition())); areas.put(area.sequence, area);
execution.own(area); area.refresh(); execution.later(area.settings.duration(), execution::finish);
execution.own(area);
area.application = execution.effectApplied("rallying_cry_area", null,
Map.of("radius", Double.toString(area.settings.radius()),
"duration_seconds", seconds(area.settings.duration())));
area.refresh(); execution.later(area.settings.duration(), () -> {
area.endReason = "expired"; execution.resolve("expired"); execution.finish();
});
}
private AutoCloseable speed(Player player, int amplifier) {
var state = speeds.computeIfAbsent(player.getUniqueId(), ignored -> new SpeedState(player));
var grant = new SpeedGrant(state, amplifier); state.grants.add(grant); state.refresh(); return grant;
}
private void breakArea(Area area) { area.close(); area.execution.finish(); }
private void breakArea(Area area) {
area.endReason = "eligibility_changed"; area.close();
area.execution.resolve("interrupted"); area.execution.finish();
}
private static boolean inside(Player source, Player target, double radius) {
try { var a = source.getLocation(); var b = target.getLocation();
return a.getWorld() == b.getWorld() && a.distanceSquared(b) <= radius * radius;
@@ -220,7 +244,10 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
@Override public void close() {
if (closed) return; closed = true; if (sweepTask != null) sweepTask.cancel();
for (var area : List.copyOf(areas.values())) area.execution.finish(); areas.clear();
for (var area : List.copyOf(areas.values())) {
area.endReason = "shutdown"; area.execution.resolve("interrupted"); area.execution.finish();
}
areas.clear();
for (var state : List.copyOf(speeds.values())) { state.grants.clear(); state.refresh(); }
speeds.clear();
}
@@ -52,6 +52,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
private final CharacterSheets sheets = new CharacterSheets();
private games.dmg.fantasystick.session.DownedController downed;
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
private games.dmg.fantasystick.events.ReviveEventBridge reviveEvents;
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<>();
@@ -62,16 +63,22 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
final Player target;
final Inventory inventory;
final Map<Integer, Choice> choices;
final games.dmg.fantasystick.events.ReviveEventBridge.Cast cast;
boolean selecting;
boolean eventResolved;
boolean finished;
View(AbilityExecution execution, Player target, Inventory inventory, Map<Integer, Choice> choices) {
this.execution = execution; this.target = target; this.inventory = inventory; this.choices = choices;
View(AbilityExecution execution, Player target, Inventory inventory, Map<Integer, Choice> choices,games.dmg.fantasystick.events.ReviveEventBridge.Cast cast) {
this.execution = execution; this.target = target; this.inventory = inventory; this.choices = choices;this.cast=cast;
}
void resolve(String outcome,String stage){if(cast!=null&&!eventResolved){eventResolved=true;reviveEvents.resolved(cast,outcome,stage);}}
void fail(String reason){if(cast!=null&&!eventResolved){eventResolved=true;reviveEvents.failed(cast,reason);}}
void revived(RevivalStore.Reservation reservation,CharacterRecord dead,CharacterRecord restored){if(cast!=null&&!eventResolved){eventResolved=true;reviveEvents.revived(cast,reservation,dead,restored,target);}}
@Override public void close() {
if (finished) return;
finished = true;
inventories.remove(inventory, this); targets.remove(target.getUniqueId(), this);
inventory.clear();
if(!selecting)resolve("cancelled_or_expired","tail_selection_closed");
try { target.closeInventory(); } catch (RuntimeException failure) { report(failure); }
}
}
@@ -85,6 +92,7 @@ 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 AbilityDefinition definition() {
return new AbilityDefinition("revive", "Revive", Material.ECHO_SHARD,
@@ -107,15 +115,16 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
execution.finish(); return;
}
if(downed!=null&&downed.downed(target)){var episode=downedEpisode(target);if(episode==null){execution.finish();return;}var action=UUID.randomUUID();var caster=execution.character();var cooldown=caster.cooldowns().get("revive");var distance=execution.input().player().getLocation().distance(target.getLocation());var castAt=clock.instant();var decision=new games.dmg.fantasystick.session.DownedEpisode.Decision("revive",execution.input().player().getUniqueId(),execution.input().player().getName(),caster.id(),caster.name(),execution.input().session().session(),action,castAt,cooldown,distance,true);if(downedEvents!=null)downedEvents.abilityCast(episode,decision);downed.revive(target,downedProtection.get(),decision,status->{if(downedEvents!=null&&status!=games.dmg.fantasystick.session.DownedController.RescueStatus.APPLIED&&status!=games.dmg.fantasystick.session.DownedController.RescueStatus.APPLIED_PENDING_RECOVERY)downedEvents.abilityResolved(episode,decision,status.name().toLowerCase(java.util.Locale.ROOT),clock.instant());execution.input().player().sendMessage(switch(status){case APPLIED->"Revive restored the downed character in place.";case APPLIED_PENDING_RECOVERY->"Revive restored the downed character; durable publication is pending recovery.";case EXPIRED->"Revive arrived after the downed countdown expired.";case STALE->"That downed episode was already resolved or changed.";case FAILED->"Downed Revive is pending recovery; no second rescue was attempted.";});execution.finish();});return;}
var cast=reviveEvents==null?null:reviveEvents.cast(execution,target);
var captured = candidates(target);
if (captured.isEmpty()) {
execution.input().player().sendMessage("That player has no direct-inventory Tail eligible for Revive.");
if(cast!=null)reviveEvents.resolved(cast,"no_eligible_tail","tail_scan");execution.input().player().sendMessage("That player has no direct-inventory Tail eligible for Revive.");
execution.finish(); return;
}
var loads = captured.stream().map(candidate -> characters.find(candidate.identity().characterId())).toList();
CompletableFuture.allOf(loads.toArray(CompletableFuture[]::new)).whenComplete((ignored, failure) -> schedule(() -> {
if (failure != null) { report(failure); execution.input().player().sendMessage("Revive could not inspect those Tails safely."); execution.finish(); return; }
if (closed || !execution.stillCurrent() || !target.isOnline()) { execution.finish(); return; }
if (failure != null) { if(cast!=null)reviveEvents.failed(cast,"tail_inspection_failed");report(failure); execution.input().player().sendMessage("Revive could not inspect those Tails safely."); execution.finish(); return; }
if (closed || !execution.stillCurrent() || !target.isOnline()) {if(cast!=null)reviveEvents.resolved(cast,"stale","tail_inspection"); execution.finish(); return; }
var eligible = new ArrayList<Choice>();
Instant now = clock.instant();
for (int index = 0; index < captured.size(); index++) {
@@ -126,10 +135,10 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
if (eligible(record, candidate.identity(), now) && unchanged(target, candidate)) eligible.add(new Choice(candidate, record));
}
if (eligible.isEmpty()) {
execution.input().player().sendMessage("That player has no current Tail younger than 24 hours.");
if(cast!=null)reviveEvents.resolved(cast,"expired_or_stale","tail_eligibility");execution.input().player().sendMessage("That player has no current Tail younger than 24 hours.");
execution.finish(); return;
}
open(execution, target, eligible, now);
open(execution, target, eligible, now,cast);
}));
}
@@ -174,7 +183,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
return current != null && current.equals(candidate.captured());
}
private void open(AbilityExecution execution, Player target, List<Choice> eligible, Instant now) {
private void open(AbilityExecution execution, Player target, List<Choice> eligible, Instant now,games.dmg.fantasystick.events.ReviveEventBridge.Cast cast) {
View old = targets.get(target.getUniqueId()); if (old != null) old.execution.finish();
int size = Math.min(54, Math.max(9, ((eligible.size() + 8) / 9) * 9));
Inventory inventory = popups.create(size, Component.text("Choose a Tail to Revive"));
@@ -187,7 +196,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
Component.text("Eligible for " + seconds + " more seconds"), Component.text("Click to continue this character")));
display.setItemMeta(meta); inventory.setItem(index, display); choices.put(index, choice);
}
var view = new View(execution, target, inventory, Map.copyOf(choices));
var view = new View(execution, target, inventory, Map.copyOf(choices),cast);
inventories.put(inventory, view); targets.put(target.getUniqueId(), view); execution.own(view);
target.openInventory(inventory);
execution.later(VIEW_TIME, execution::finish);
@@ -231,14 +240,14 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
private void confirm(View view, Choice choice) {
if (closed || !view.execution.stillCurrent() || !view.target.isOnline() || !unchanged(view.target, choice.candidate())) {
view.execution.finish(); return;
view.resolve("stale","tail_confirmation");view.execution.finish(); return;
}
characters.find(choice.record().id()).whenComplete((found, failure) -> schedule(() -> {
if (failure != null) { report(failure); view.execution.input().player().sendMessage("Revive could not recheck that Tail; no item changed."); view.execution.finish(); return; }
if (failure != null) {view.fail("tail_recheck_failed"); report(failure); view.execution.input().player().sendMessage("Revive could not recheck that Tail; no item changed."); view.execution.finish(); return; }
if (closed || !view.execution.stillCurrent() || !view.target.isOnline() || found.isEmpty()
|| !eligible(found.orElseThrow(), choice.candidate().identity(), clock.instant())
|| !unchanged(view.target, choice.candidate())) {
view.execution.input().player().sendMessage("That Tail is no longer eligible; no item changed.");
view.resolve("expired_or_stale","tail_confirmation");view.execution.input().player().sendMessage("That Tail is no longer eligible; no item changed.");
view.execution.finish(); return;
}
reserve(view, choice.candidate(), found.orElseThrow());
@@ -249,17 +258,17 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
ItemStack source = view.target.getInventory().getItem(candidate.slot());
int destination = source.getAmount() == 1 ? -1 : emptyDirectSlot(view.target, candidate.slot());
if (source.getAmount() > 1 && destination < 0) {
view.target.sendMessage("Revive needs one empty direct inventory slot to preserve the copied Tails.");
view.resolve("unsafe_capacity","tail_reservation");view.target.sendMessage("Revive needs one empty direct inventory slot to preserve the copied Tails.");
view.execution.finish(); return;
}
var reservation = new RevivalStore.Reservation(UUID.randomUUID(), view.execution.character().id(),
view.target.getUniqueId(), dead.id(), dead.deathId(), clock.instant().truncatedTo(ChronoUnit.SECONDS),
candidate.slot(), source.getAmount(), destination);
reservations.reserve(reservation).whenComplete((reserved, failure) -> schedule(() -> {
if (failure != null) { report(failure); view.execution.input().player().sendMessage("Revive reservation could not be saved; no Tail changed."); view.execution.finish(); return; }
if (failure != null) {view.fail("reservation_save_failed"); report(failure); view.execution.input().player().sendMessage("Revive reservation could not be saved; no Tail changed."); view.execution.finish(); return; }
if (reserved.isEmpty() || closed || !view.execution.stillCurrent() || !view.target.isOnline()
|| !unchanged(view.target, candidate)) {
view.execution.input().player().sendMessage("Revive selection became stale after reservation; no Tail was changed.");
view.resolve("stale","tail_reservation");view.execution.input().player().sendMessage("Revive selection became stale after reservation; no Tail was changed.");
view.execution.finish(); return;
}
deliver(view, dead, reservation);
@@ -290,22 +299,22 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
}
view.target.updateInventory();
} catch (RuntimeException uncertain) {
report(uncertain); view.target.sendMessage("Revive item delivery is uncertain; retained proof requires recovery.");
view.fail("native_tail_delivery_uncertain");report(uncertain); view.target.sendMessage("Revive item delivery is uncertain; retained proof requires recovery.");
view.execution.finish(); return;
}
var nativeLimit = games.dmg.fantasystick.session.NativeHealthLimit.read();
characters.restoreDeathRecord(reservation.character(), reservation.death(), nativeLimit)
.whenComplete((restored, failure) -> schedule(() -> {
if (failure != null || restored.isEmpty()) {
if (failure != null) report(failure);
view.fail(failure!=null?"character_restore_failed":"character_restore_unavailable");if (failure != null) report(failure);
view.target.sendMessage("The Tail changed, but character restoration is pending recovery; do not move the sheet.");
view.execution.finish(); return;
}
if (installFinalSheet(view.target, restored.orElseThrow())) {
reservations.clear(reservation.character(), reservation.id()).exceptionally(failure2 -> { report(failure2); return false; });
view.revived(reservation,dead,restored.orElseThrow());reservations.clear(reservation.character(), reservation.id()).exceptionally(failure2 -> { report(failure2); return false; });
view.target.sendMessage("Restored character sheet: " + restored.orElseThrow().name() + ".");
view.execution.input().player().sendMessage("Revive restored " + restored.orElseThrow().name() + ".");
} else view.target.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");
} else {view.fail("restored_but_sheet_custody_ambiguous");view.target.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");}
view.execution.finish();
}));
}
@@ -349,7 +358,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)) {
reservations.clear(reservation.character(), reservation.id()); return;
if(reviveEvents!=null)reviveEvents.recovered(reservation,record,target);reservations.clear(reservation.character(), reservation.id()); return;
}
if (record.dead() && unchangedTail && !hasSheet) {
reservations.clear(reservation.character(), reservation.id()); return;
@@ -366,7 +375,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())) {
reservations.clear(reservation.character(), reservation.id());
if(reviveEvents!=null)reviveEvents.recovered(reservation,restored.orElseThrow(),target);reservations.clear(reservation.character(), reservation.id());
}
}));
}
@@ -53,23 +53,30 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
private final class State implements AutoCloseable {
final AbilityExecution execution; final Player source;
final Location origin; final Settings captured; final Map<UUID, AutoCloseable> mitigation = new LinkedHashMap<>();
final Location origin; final Settings captured; final Map<UUID, Benefit> mitigation = new LinkedHashMap<>();
Duration elapsed = Duration.ZERO; Duration nextPulse; final Duration scanPeriod;
int pending; boolean expired; boolean closed;
UUID areaApplication; int pulseOrdinal;
String endReason = "interrupted"; int pending; boolean expired; boolean closed;
State(AbilityExecution execution, Settings captured) {
this.execution = execution; this.source = execution.input().player(); this.origin = source.getLocation().clone();
this.captured = captured; this.nextPulse = captured.pulseInterval();
long period = Math.max(50_000_000L, (captured.duration().toNanos() + 59) / 60);
this.scanPeriod = Duration.ofNanos(period);
}
void start() { states.add(this); execution.own(this); reconcile(); visual(); schedule(); }
void start() {
states.add(this); execution.own(this);
areaApplication = execution.effectApplied("sanctuary_area", null,
Map.of("radius", captured.radius(), "vertical_range", captured.verticalRange(),
"duration_seconds", seconds(captured.duration()), "area_location", locationData(origin)));
reconcile(); visual(); schedule();
}
void schedule() {
if (closed || expired) return;
Duration remaining = captured.duration().minus(elapsed);
execution.later(remaining.compareTo(scanPeriod) < 0 ? remaining : scanPeriod, this::step);
}
void step() {
if (closed || !execution.stillCurrent()) { execution.finish(); return; }
if (closed || !execution.stillCurrent()) { execution.resolve("interrupted"); execution.finish(); return; }
Duration remaining = captured.duration().minus(elapsed);
elapsed = elapsed.plus(remaining.compareTo(scanPeriod) < 0 ? remaining : scanPeriod);
reconcile(); visual();
@@ -77,20 +84,22 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
pulse(); nextPulse = nextPulse.plus(captured.pulseInterval());
}
if (elapsed.compareTo(captured.duration()) >= 0) {
expired = true; closeMitigation(); if (pending == 0) execution.finish();
expired = true; endReason = "expired"; closeMitigation();
execution.resolve("expired", Map.of("pulse_count", pulseOrdinal));
if (pending == 0) execution.finish();
} else schedule();
}
void reconcile() {
var eligible = recipients();
for (var entry : List.copyOf(mitigation.entrySet())) {
if (eligible.stream().noneMatch(player -> player.getUniqueId().equals(entry.getKey()))) {
try { entry.getValue().close(); } catch (Exception ignored) { } mitigation.remove(entry.getKey());
entry.getValue().close(); mitigation.remove(entry.getKey());
}
}
for (var player : eligible) mitigation.computeIfAbsent(player.getUniqueId(), ignored ->
combatMitigation.grant(execution, player, captured.damageReductionPercent(), () -> recipientCurrent(player)));
for (var player : eligible) mitigation.computeIfAbsent(player.getUniqueId(), ignored -> new Benefit(this, player));
}
void pulse() {
pulseOrdinal++;
for (var player : recipients()) if (owner(player) == this) heal(player);
}
void heal(Player player) {
@@ -141,11 +150,46 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
captured.radius() / 2, 0.1, captured.radius() / 2, 0); } catch (RuntimeException ignored) { }
}
void closeMitigation() {
for (var grant : List.copyOf(mitigation.values())) try { grant.close(); } catch (Exception ignored) { }
for (var grant : List.copyOf(mitigation.values())) grant.close();
mitigation.clear();
}
@Override public void close() {
if (closed) return; closed = true; expired = true; states.remove(this); closeMitigation();
if (areaApplication != null) execution.effectEnded(areaApplication, endReason);
}
}
private final class Benefit implements AutoCloseable {
final State state; final Player player; final AutoCloseable grant;
UUID application; boolean closed;
Benefit(State state, Player player) {
this.state = state; this.player = player;
grant = combatMitigation.grant(state.execution, player, state.captured.damageReductionPercent(),
() -> !closed && state.recipientCurrent(player), (event, amount) -> {
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
var attacker = event.getDamageSource().getCausingEntity();
if (attacker == null && event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
attacker = byEntity.getDamager();
if (active != null && attacker != null && amount > 0)
state.execution.acceptedPrevention(active, player, attacker,
event.getDamage() + amount, amount, event.getFinalDamage(), application);
});
try {
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
var evidence = new LinkedHashMap<String, Object>();
evidence.put("damage_reduction_percent", state.captured.damageReductionPercent().toPlainString());
if (active != null) {
evidence.put("recipient_character_id", active.character().toString());
evidence.put("recipient_session_id", active.session().toString());
}
application = state.execution.effectApplied("sanctuary_benefit", player, evidence);
} catch (RuntimeException ignored) { /* Auditing cannot refuse a native mitigation grant. */ }
}
@Override public void close() {
if (closed) return; closed = true;
try { grant.close(); } catch (Exception ignored) { }
if (application != null) state.execution.effectEnded(application,
state.closed || state.expired ? state.endReason : "recipient_eligibility_changed");
}
}
@@ -209,10 +253,15 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
double desired = Math.min(maximum, persisted.health().doubleValue()); double restored = Math.min(accepted, desired - current);
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) return;
target.player().setHealth(Math.min(maximum, current + restored));
double after = target.player().getHealth();
double actual = after - current;
if (!Double.isFinite(actual) || actual <= 0) return;
state.execution.acceptedHeal(target.active(), target.player(), persisted,
accepted, actual, current, after, maximum);
state.execution.report(AbilityReport.Type.HEALING, target.player(),
BigDecimal.valueOf(restored).stripTrailingZeros());
BigDecimal.valueOf(actual).stripTrailingZeros());
if (target.player() != state.source) supports.accept(new MendingLight.Support(
state.execution.input().session().character(), target.active(), BigDecimal.valueOf(restored).stripTrailingZeros()));
state.execution.input().session().character(), target.active(), BigDecimal.valueOf(actual).stripTrailingZeros()));
} finally { completePending(state); }
}
private static void completePending(State state) {
@@ -240,6 +289,13 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
return world.rayTraceBlocks(origin, offset.multiply(1 / distance), distance, org.bukkit.FluidCollisionMode.NEVER, true) == null;
} catch (RuntimeException invalid) { return false; }
}
private static Map<String, Object> locationData(Location location) {
var world = location == null ? null : location.getWorld();
if (world == null || world.getUID() == null || world.getName() == null)
return Map.of("location_status", "unavailable");
return Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
"x", location.getX(), "y", location.getY(), "z", location.getZ());
}
private static double distance(Location origin, Player player) {
try { var at = player.getLocation(); if (origin.getWorld() != at.getWorld()) return Double.POSITIVE_INFINITY;
double dx = at.getX() - origin.getX(), dz = at.getZ() - origin.getZ(); return dx * dx + dz * dz;
@@ -55,13 +55,13 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
private final class Area implements AutoCloseable {
final long sequence = ++sequences; final AbilityExecution execution; final Player source;
final Location origin; final Settings captured; final Map<UUID, Benefit> recipients = new LinkedHashMap<>();
boolean closed;
UUID application; String endReason = "interrupted"; boolean closed;
Area(AbilityExecution execution, Location origin, Settings captured) {
this.execution = execution; this.source = execution.input().player(); this.origin = origin; this.captured = captured;
}
void refresh() {
if (closed) return;
if (!sourceCurrent()) { execution.finish(); return; }
if (!sourceCurrent()) { endReason = "eligibility_changed"; execution.resolve("interrupted"); execution.finish(); return; }
var eligible = candidates();
for (var entry : List.copyOf(recipients.entrySet())) if (!eligible.containsKey(entry.getKey())) entry.getValue().close();
for (var entry : eligible.entrySet()) recipients.computeIfAbsent(entry.getKey(), ignored -> new Benefit(this, entry.getValue()));
@@ -104,20 +104,36 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
@Override public void close() {
if (closed) return; closed = true; areas.remove(sequence, this);
for (var benefit : List.copyOf(recipients.values())) benefit.close(); recipients.clear();
if (application != null) execution.effectEnded(application, endReason);
}
}
private final class Benefit implements AutoCloseable {
final Area area; final Player player; final AutoCloseable bonus; final AutoCloseable resistance; boolean closed;
final Area area; final Player player; final AutoCloseable bonus; final AutoCloseable resistance;
UUID application; boolean closed;
Benefit(Area area, Player player) {
this.area = area; this.player = player;
BooleanSupplier current = () -> !closed && area.eligible(player);
bonus = bonuses.grant(area.execution, player, area.captured.damageBonusPercent(), current);
resistance = resistance(player, area.captured.knockbackResistancePercent(), current);
try {
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
var evidence = new LinkedHashMap<String, Object>();
evidence.put("damage_bonus_percent", area.captured.damageBonusPercent().toPlainString());
evidence.put("knockback_resistance_percent", area.captured.knockbackResistancePercent().toPlainString());
evidence.put("area_location", locationData(area.origin));
if (active != null) {
evidence.put("recipient_character_id", active.character().toString());
evidence.put("recipient_session_id", active.session().toString());
}
application = area.execution.effectApplied("war_banner_benefit", player, evidence);
} catch (RuntimeException ignored) { /* Native grant remains authoritative despite audit failure. */ }
}
@Override public void close() {
if (closed) return; closed = true; area.recipients.remove(player.getUniqueId(), this);
closeResource(bonus); closeResource(resistance);
if (application != null) area.execution.effectEnded(application,
area.closed ? area.endReason : "recipient_eligibility_changed");
}
}
@@ -202,9 +218,14 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return; var captured = from(execution.definition());
var origin = placement(execution.input().player(), captured.placementRange());
if (origin == null) { execution.finish(); return; }
if (origin == null) { execution.resolve("miss", Map.of("reason", "placement_unavailable")); execution.finish(); return; }
var area = new Area(execution, origin, captured); areas.put(area.sequence, area); execution.own(area);
area.refresh(); execution.later(captured.duration(), execution::finish);
area.application = execution.effectApplied("war_banner_area", null,
Map.of("area_location", locationData(origin), "radius", captured.radius(),
"vertical_range", captured.verticalRange(), "duration_seconds", seconds(captured.duration())));
area.refresh(); execution.later(captured.duration(), () -> {
area.endReason = "expired"; execution.resolve("expired"); execution.finish();
});
}
private AutoCloseable resistance(Player player, BigDecimal percent, BooleanSupplier current) {
@@ -231,6 +252,13 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
return world.rayTraceBlocks(origin,offset.multiply(1/distance),distance,FluidCollisionMode.NEVER,true)==null;
} catch(RuntimeException invalid){return false;}
}
private static Map<String, Object> locationData(Location location) {
var world = location == null ? null : location.getWorld();
if (world == null || world.getUID() == null || world.getName() == null)
return Map.of("location_status", "unavailable");
return Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
"x", location.getX(), "y", location.getY(), "z", location.getZ());
}
private static double distance(Location origin, Player player) {
try { var at=player.getLocation();if(origin.getWorld()!=at.getWorld())return Double.POSITIVE_INFINITY;
double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz;
@@ -38,6 +38,9 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
private final Map<UUID, games.dmg.fantasystick.events.BookEventBridge.Request> pendingBookEvents = new ConcurrentHashMap<>();
private games.dmg.fantasystick.events.BookEventBridge bookEvents;
private games.dmg.fantasystick.events.ProgressionEventBridge progressionEvents;
private games.dmg.fantasystick.events.AbilityEventBridge abilityEvents;
private games.dmg.fantasystick.events.PassiveEventBridge passiveEvents;
private final Map<UUID, Object> adminPending = new ConcurrentHashMap<>();
private final Map<UUID, AdminView> adminViews = new ConcurrentHashMap<>();
private final Map<UUID, SavedView> savedViews = new ConcurrentHashMap<>();
@@ -63,9 +66,9 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
games.dmg.fantasystick.session.EquipmentSessions.Active active,
int previous, int next) { }
private record SavedView(String nonce, UUID administrator, UUID character, int previous, int next) { }
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision) { }
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
private record PassiveView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision, CharacterRecord before) { }
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids, CharacterRecord before) { }
private record PassiveView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids, CharacterRecord before) { }
private record RareView(String nonce,ControlInput input,java.util.Map<String,java.math.BigInteger> quantities){}
private record ReskillView(String nonce,ControlInput input,games.dmg.fantasystick.shop.RareReskillService.Preview preview){}
private final Map<UUID, View> views = new ConcurrentHashMap<>();
@@ -120,6 +123,9 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
public void attachRareInventory(games.dmg.fantasystick.shop.RareInventoryDirectory inventory,games.dmg.fantasystick.shop.RarePotionService potions,games.dmg.fantasystick.shop.RareReskillService reskills,games.dmg.fantasystick.shop.RareMagicCoreService magicCores){if(closed||rareInventory!=null)throw new IllegalStateException("Rare inventory already attached or books closed");rareInventory=Objects.requireNonNull(inventory);rarePotions=Objects.requireNonNull(potions);rareReskills=Objects.requireNonNull(reskills);rareMagicCores=Objects.requireNonNull(magicCores);}
public void attachEvents(games.dmg.fantasystick.events.BookEventBridge events){if(closed||bookEvents!=null)throw new IllegalStateException("Book events already attached or controller closed");bookEvents=Objects.requireNonNull(events);}
public void attachProgressionEvents(games.dmg.fantasystick.events.ProgressionEventBridge events){if(closed||progressionEvents!=null)throw new IllegalStateException("Progression events already attached or controller closed");progressionEvents=Objects.requireNonNull(events);}
public void attachAbilityEvents(games.dmg.fantasystick.events.AbilityEventBridge events){if(closed||abilityEvents!=null)throw new IllegalStateException("Ability events already attached or controller closed");abilityEvents=Objects.requireNonNull(events);}
public void attachPassiveEvents(games.dmg.fantasystick.events.PassiveEventBridge events){if(closed||passiveEvents!=null)throw new IllegalStateException("Passive events already attached or controller closed");passiveEvents=Objects.requireNonNull(events);}
@Override public void accept(ControlInput input) { load(input, 0, true); }
@@ -312,7 +318,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
meta.setTitle("Character");
meta.setAuthor("Fantasy Stick");
var statView = interactive ? new StatView(UUID.randomUUID().toString(), input, record.revision()) : null;
var statView = interactive ? new StatView(UUID.randomUUID().toString(), input, record.revision(), record) : null;
var allOverview = overviewPages(record, subject, statView, savedState);
int start = Math.min(offset, (allOverview.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
int end = Math.min(start + VOLUME_SIZE, allOverview.size());
@@ -335,11 +341,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
if ((!definitions.isEmpty() || !passiveDefinitions.isEmpty()) && end == allOverview.size()) {
var abilityView = interactive ? new AbilityView(UUID.randomUUID().toString(), input, record.revision(),
definitions.stream().map(games.dmg.fantasystick.ability.AbilityDefinition::id)
.collect(java.util.stream.Collectors.toUnmodifiableSet())) : null;
.collect(java.util.stream.Collectors.toUnmodifiableSet()), record) : null;
if (interactive && !definitions.isEmpty()) abilityViews.put(input.session().player(), abilityView);
var passiveView = interactive ? new PassiveView(UUID.randomUUID().toString(), input, record.revision(),
passiveDefinitions.stream().map(games.dmg.fantasystick.passive.PassiveDefinition::id)
.collect(java.util.stream.Collectors.toUnmodifiableSet())) : null;
.collect(java.util.stream.Collectors.toUnmodifiableSet()), record) : null;
if (interactive && !passiveDefinitions.isEmpty()) passiveViews.put(input.session().player(), passiveView);
var passiveNames = passiveDefinitions.stream().collect(java.util.stream.Collectors.toMap(
games.dmg.fantasystick.passive.PassiveDefinition::id,
@@ -789,49 +795,53 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
public void statAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
if (closed || !STATS.equals(event.getIdentifier())
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
var player = connection.getPlayer();
var player = connection.getPlayer();if(event.getTag()==null)return;String raw=event.getTag().string();if(raw.length()>80)return;var match=STAT_PAYLOAD.matcher(raw);if(!match.matches())return;var stat=Stat.valueOf(match.group(2));
var view = statViews.get(player.getUniqueId());
if (view == null || view.input().player() != player || event.getTag() == null) return;
String raw = event.getTag().string();
if (raw.length() > 80) return;
var match = STAT_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1))
|| !statViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
var stat = Stat.valueOf(match.group(2));
if (view == null || view.input().player() != player || !view.nonce().equals(match.group(1))
|| !statViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) { refuseStat(player,stat,"stale_forged_or_repeated_action"); return; }
var currentHealth = java.math.BigDecimal.valueOf(player.getHealth());
var nativeLimit = NativeHealthLimit.read();
characters.purchaseStat(view.input().session().character(), view.revision(), stat, currentHealth, nativeLimit)
.whenComplete((purchase, failure) -> {
try {
nextTick.accept(() -> finishPurchase(view, stat, purchase, failure));
} catch (RuntimeException rejected) { report(view.input(), rejected); }
});
var nativeMaximum=java.math.BigDecimal.valueOf(java.util.Objects.requireNonNull(player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue());
var audit=progressionEvents==null?null:progressionEvents.begin(view.input(),view.before(),stat,currentHealth,nativeMaximum,nativeLimit);
try {
characters.purchaseStat(view.input().session().character(), view.revision(), stat, currentHealth, nativeLimit)
.whenComplete((purchase, failure) -> {
try { nextTick.accept(() -> finishPurchase(view, stat, audit, purchase, failure)); }
catch (RuntimeException rejected) { if(audit!=null){if(purchase!=null&&purchase.status()==CharacterStorage.StatPurchaseStatus.APPLIED)progressionEvents.increased(audit,purchase.record().orElseThrow(),"scheduler_rejected_before_native_publication",purchase.record().orElseThrow().health());else progressionEvents.failed(audit,"scheduler_rejected");}report(view.input(), rejected); }
});
} catch (RuntimeException failure) { if(audit!=null)progressionEvents.failed(audit,"purchase_rejected");report(view.input(),failure); }
}
private void finishPurchase(StatView view, Stat stat, CharacterStorage.StatPurchase purchase, Throwable failure) {
private void refuseStat(org.bukkit.entity.Player player,Stat stat,String reason){if(progressionEvents==null)return;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var current=sessions.currentRecord(active).orElse(null);if(current!=null)progressionEvents.refused(player,active,current,stat,NativeHealthLimit.read(),reason);}
private void finishPurchase(StatView view, Stat stat, games.dmg.fantasystick.events.ProgressionEventBridge.Action audit, CharacterStorage.StatPurchase purchase, Throwable failure) {
var input = view.input();
if (closed || !input.stillCurrent()) return;
if (failure != null) {
report(input, failure);
input.player().sendMessage("Stat purchase could not be saved; no character state was changed.");
if(audit!=null)progressionEvents.failed(audit,"save_failed");report(input, failure);
if(!closed&&input.player().isOnline())input.player().sendMessage("Stat purchase could not be saved; no character state was changed.");
return;
}
if (purchase.status() == CharacterStorage.StatPurchaseStatus.APPLIED) {
var saved = purchase.record().orElseThrow();
if(closed||!input.stillCurrent()){if(audit!=null)progressionEvents.increased(audit,saved,closed?"plugin_shutdown_before_native_publication":"session_changed_before_native_publication",saved.health());return;}
try {
if (!sessions.acknowledgeProgress(input.session(), view.revision(), saved)) {
if(audit!=null)progressionEvents.increased(audit,saved,"session_changed_before_native_publication",saved.health());
input.player().sendMessage("Stat purchase was saved; reopen the character after the session transition.");
return;
}
} catch (RuntimeException nativeFailure) {
report(input, nativeFailure);
if(audit!=null)progressionEvents.increased(audit,saved,"native_publication_failed",saved.health());report(input, nativeFailure);
input.player().sendMessage("Stat purchase was saved, but native health refresh failed; exit for recovery.");
return;
}
if(audit!=null)progressionEvents.increased(audit,saved,"acknowledged",java.math.BigDecimal.valueOf(input.player().getHealth()));
input.player().sendMessage(stat + " increased to " + saved.stats().get(stat) + ".");
load(input, 0, false);
return;
}
if(audit!=null)progressionEvents.refused(audit,purchase.status().name().toLowerCase(java.util.Locale.ROOT),purchase.record().orElse(null));
if(closed||!input.stillCurrent())return;
String message = switch (purchase.status()) {
case STALE -> "That character page is stale; reopen it before spending points.";
case NO_POINTS -> "This character has no unspent points.";
@@ -849,18 +859,16 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
if (closed || abilityEngine == null || !unlock && !unlearn
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|| event.getTag() == null) return;
var player = connection.getPlayer();
var player = connection.getPlayer();String raw=event.getTag().string();if(raw.length()>120)return;var match=ABILITY_PAYLOAD.matcher(raw);if(!match.matches())return;String ability=match.group(2);
var view = abilityViews.get(player.getUniqueId());
if (view == null || view.input().player() != player) return;
String raw = event.getTag().string();
if (raw.length() > 120) return;
var match = ABILITY_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1)) || !view.ids().contains(match.group(2))
|| !abilityViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
String ability = match.group(2);
boolean audited=unlearn||unlock&&("revive".equals(ability)||abilityEngine.eventsEnabledFor(ability));
if (view == null || view.input().player() != player || !view.nonce().equals(match.group(1)) || !view.ids().contains(ability)
|| !abilityViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) {if(audited)refuseAbilityMutation(player,ability,unlock,"stale_forged_or_repeated_action");return;}
var audit=audited&&abilityEvents!=null?abilityEvents.begin(view.input(),view.before(),ability,unlock):null;
var operation = unlock ? abilityEngine.purchase(view.input(), ability, view.revision())
: abilityEngine.unlearn(view.input(), ability, view.revision());
operation.whenComplete((mutation, failure) -> {
if(audit!=null){if(failure!=null)abilityEvents.failed(audit,"save_or_native_cleanup_uncertain");else abilityEvents.resolved(audit,mutation);}
if (closed || !view.input().stillCurrent()) return;
if (failure != null) {
report(view.input(), failure);
@@ -872,6 +880,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
});
}
private void refuseAbilityMutation(org.bukkit.entity.Player player,String ability,boolean learn,String reason){if(abilityEvents==null||abilities.find(ability).isEmpty())return;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var current=sessions.currentRecord(active).orElse(null);if(current!=null)abilityEvents.refused(player,active,current,ability,learn,reason);}
@org.bukkit.event.EventHandler
public void passiveAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
boolean unlock = PASSIVE_UNLOCK.equals(event.getIdentifier());
@@ -879,16 +889,14 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
if (closed || passiveEngine == null || !unlock && !unlearn
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|| event.getTag() == null) return;
var player = connection.getPlayer(); var view = passiveViews.get(player.getUniqueId());
if (view == null || view.input().player() != player) return;
String raw = event.getTag().string(); if (raw.length() > 120) return;
var match = ABILITY_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1)) || !view.ids().contains(match.group(2))
|| !passiveViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
String passive = match.group(2);
var player=connection.getPlayer();String raw=event.getTag().string();if(raw.length()>120)return;var match=ABILITY_PAYLOAD.matcher(raw);if(!match.matches())return;String passive=match.group(2);var definition=passives.find(passive).orElse(null);if(definition==null)return;var view=passiveViews.get(player.getUniqueId());boolean owned=games.dmg.fantasystick.passive.QuickerRegenerationI.ID.equals(passive);
if (view == null || view.input().player() != player || !view.nonce().equals(match.group(1)) || !view.ids().contains(passive)
|| !passiveViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) {if(owned)refusePassive(player,definition,unlock,"stale_forged_or_repeated_action");return;}
var audit=owned&&passiveEvents!=null?passiveEvents.begin(view.input(),view.before(),definition,unlock):null;
var operation = unlock ? passiveEngine.purchase(view.input(), passive, view.revision())
: passiveEngine.unlearn(view.input(), passive, view.revision());
operation.whenComplete((mutation, failure) -> {
if(audit!=null){if(failure!=null)passiveEvents.failed(audit,"save_or_native_publication_uncertain");else passiveEvents.resolved(audit,mutation);}
if (closed || !view.input().stillCurrent()) return;
if (failure != null) {
report(view.input(), failure);
@@ -900,6 +908,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
});
}
private void refusePassive(org.bukkit.entity.Player player,games.dmg.fantasystick.passive.PassiveDefinition definition,boolean learn,String reason){if(passiveEvents==null)return;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var current=sessions.currentRecord(active).orElse(null);if(current!=null)passiveEvents.refused(player,active,current,definition,learn,reason);}
private static String passiveMessage(boolean unlock, String passive,
CharacterStorage.PassiveMutationStatus status) {
return switch (status) {
@@ -0,0 +1,266 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.ability.AbilityDefinition;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.ControlInput;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Entity;
/** Shared cast and explicit effect-observation boundary; never infers effects from feedback. */
public final class AbilityCastEventBridge {
public record Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
String targetType, Map<String, Object> targetLocation, Instant requestedAt) { }
private final Consumer<EventInput> sink;
private final Clock clock;
public AbilityCastEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
}
public Cast begin(ControlInput input, CharacterRecord before, AbilityDefinition definition) {
try {
var player = input.player();
var target = input.target();
var targetAtRequest = target == null ? null : target.getLocation();
var cast = new Cast(UUID.randomUUID(),
new EventInput.Context(player.getUniqueId(), player.getName(), input.session().character(),
before.name(), input.session().session(), location(player.getLocation())),
definition.id(), target == null ? null : target.getUniqueId(),
target == null ? null : target instanceof org.bukkit.entity.Player ? "PLAYER" : target.getType().name(),
targetAtRequest == null ? Map.of() : locationData(targetAtRequest),
clock.instant());
emit(cast, "action.observed", "observed", Map.of("action_kind", "ability_cast"));
return cast;
} catch (RuntimeException ignored) {
return null; // Auditing may never refuse a cast or change its authority.
}
}
public void cast(Cast cast, CharacterRecord saved, Instant deadline, AbilityDefinition definition) {
if (cast == null) return;
try {
var settings = new LinkedHashMap<String, Object>();
definition.parameters().forEach((key, value) -> settings.put(key, value.toPlainString()));
var data = new LinkedHashMap<String, Object>();
data.put("cooldown_until", deadline);
data.put("after_revision", saved.revision().toString());
data.put("stage", "cooldown_committed");
data.put("activation", definition.activation().name().toLowerCase(java.util.Locale.ROOT));
data.put("captured_settings", settings);
data.put("settings_scope", "definition_at_request");
for (Stat stat : definition.requirements().keySet())
data.put("captured_" + stat.name().toLowerCase(java.util.Locale.ROOT), saved.stats().get(stat).toString());
emit(cast, "ability.cast", "accepted", data);
} catch (RuntimeException ignored) { /* Capturing metadata cannot refuse a saved cast. */ }
}
public void resolved(Cast cast, String outcome, String reason) {
resolved(cast, outcome, reason, Map.of());
}
public void resolved(Cast cast, String outcome, String reason, Map<String, Object> evidence) {
if (cast == null) return;
var data = new LinkedHashMap<String, Object>(evidence);
data.put("reason", reason);
emit(cast, "ability.resolved", outcome, data);
}
public UUID effectApplied(Cast cast, String effectId, Entity target, Map<String, Object> evidence) {
Objects.requireNonNull(effectId);
Objects.requireNonNull(evidence);
UUID application = UUID.randomUUID();
if (cast != null) {
try {
var data = new LinkedHashMap<String, Object>(evidence);
data.put("effect_id", effectId);
data.put("application_id", application.toString());
if (target != null) {
data.put("target_entity_id", target.getUniqueId().toString());
data.put("target_type", entityType(target));
var place = target.getLocation();
if (place != null) data.put("target_location", locationData(place));
}
emit(cast, "effect.applied", "applied", data, application);
} catch (RuntimeException ignored) { /* An observation failure cannot interrupt an applied effect. */ }
}
return application;
}
public void acceptedDamage(Cast cast, Entity target, double requested, double actual,
Entity direct, Map<String, Object> evidence) {
if (cast == null || !Double.isFinite(requested) || !Double.isFinite(actual) || actual <= 0) return;
try {
var data = new LinkedHashMap<String, Object>(evidence);
var application = UUID.randomUUID();
data.put("application_id", application.toString());
data.put("causation_id", cast.actionId().toString());
data.put("requested_amount", requested);
data.put("actual_amount", actual);
data.put("target_entity_id", target.getUniqueId().toString());
data.put("target_type", entityType(target));
var targetAtApplication = target.getLocation();
if (targetAtApplication != null) data.put("target_location", locationData(targetAtApplication));
if (target instanceof org.bukkit.entity.Player player) data.put("target_player_id", player.getUniqueId().toString());
data.put("direct_entity_id", direct.getUniqueId().toString());
data.put("direct_entity_type", entityType(direct));
emit(cast, "combat.damage", "accepted", data, application);
} catch (RuntimeException ignored) { /* Native damage remains authoritative even when auditing fails. */ }
}
public void healed(Cast cast, org.bukkit.entity.Player target, UUID aidedCharacter, UUID aidedSession,
CharacterRecord saved, double requested, double actual, double before, double after,
double maximum) {
if (cast == null || !Double.isFinite(actual) || actual <= 0) return;
try {
var data = new LinkedHashMap<String, Object>();
var application = UUID.randomUUID();
data.put("application_id", application.toString());
data.put("causation_id", cast.actionId().toString());
data.put("target_player_id", target.getUniqueId().toString());
data.put("target_character_id", aidedCharacter.toString());
data.put("target_session_id", aidedSession.toString());
data.put("target_location", locationData(target.getLocation()));
data.put("after_revision", saved.revision().toString());
data.put("requested_amount", decimal(requested));
data.put("actual_amount", decimal(actual));
data.put("health_before", decimal(before));
data.put("health_after", decimal(after));
data.put("maximum_health", decimal(maximum));
emit(cast, "combat.healed", "acknowledged", data, application);
} catch (RuntimeException ignored) { /* Healing remains authoritative despite logging failure. */ }
}
private static String decimal(double value) {
if (!Double.isFinite(value)) throw new IllegalArgumentException("Non-finite health observation");
return java.math.BigDecimal.valueOf(value).stripTrailingZeros().toPlainString();
}
public void cleansed(Cast cast, org.bukkit.entity.Player target, UUID aidedCharacter,
UUID aidedSession, java.util.List<String> removed) {
if (cast == null || removed == null || removed.isEmpty()) return;
try {
var data = new LinkedHashMap<String, Object>();
var application = UUID.randomUUID();
data.put("application_id", application.toString());
data.put("causation_id", cast.actionId().toString());
data.put("target_player_id", target.getUniqueId().toString());
data.put("target_character_id", aidedCharacter.toString());
data.put("target_session_id", aidedSession.toString());
data.put("target_location", locationData(target.getLocation()));
data.put("self_target", aidedCharacter.equals(cast.context().characterId()));
data.put("removed_effects", java.util.List.copyOf(removed));
data.put("actual_count", Integer.toString(removed.size()));
emit(cast, "combat.cleansed", "accepted", data, application);
} catch (RuntimeException ignored) { /* Native cleanse remains authoritative. */ }
}
public void prevented(Cast cast, org.bukkit.entity.Player ally, UUID aidedCharacter,
UUID aidedSession, Entity attacker, double incoming, double actual,
double remaining, UUID bondApplication) {
if (cast == null || !Double.isFinite(actual) || actual <= 0) return;
try {
var data = new LinkedHashMap<String, Object>();
var application = UUID.randomUUID();
data.put("application_id", application.toString());
data.put("causation_id", cast.actionId().toString());
if (bondApplication != null) data.put("bond_application_id", bondApplication.toString());
data.put("target_player_id", ally.getUniqueId().toString());
data.put("target_character_id", aidedCharacter.toString());
data.put("target_session_id", aidedSession.toString());
data.put("target_location", locationData(ally.getLocation()));
data.put("attacker_entity_id", attacker.getUniqueId().toString());
data.put("attacker_entity_type", entityType(attacker));
data.put("incoming_amount", decimal(incoming));
data.put("actual_amount", decimal(actual));
data.put("remaining_amount", decimal(remaining));
emit(cast, "combat.prevented", "accepted", data, application);
} catch (RuntimeException ignored) { /* Prevention authority belongs to the native damage event. */ }
}
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) {
if (cast == null || !Double.isFinite(finalDamage) || finalDamage <= 0
|| !Double.isFinite(bonus) || bonus <= 0) return;
try {
var data = new LinkedHashMap<String, Object>();
var application = UUID.randomUUID();
data.put("application_id", application.toString());
data.put("causation_id", cast.actionId().toString());
data.put("attacker_player_id", attacker.getUniqueId().toString());
if (attacking != null) {
data.put("attacker_character_id", attacking.character().toString());
data.put("attacker_session_id", attacking.session().toString());
}
data.put("target_entity_id", target.getUniqueId().toString());
data.put("target_type", entityType(target));
var location = target.getLocation();
if (location != null && location.getWorld() != null) data.put("target_location", locationData(location));
data.put("base_amount", decimal(base));
data.put("actual_amount", decimal(bonus));
data.put("final_amount", decimal(finalDamage));
data.put("winning_bonus_percent", percent.stripTrailingZeros().toPlainString());
data.put("selection", "strongest_applicable_only");
emit(cast, "combat.boosted", "accepted", data, application);
} catch (RuntimeException ignored) { /* Native combat remains authoritative. */ }
}
private static String entityType(Entity entity) {
if (entity instanceof org.bukkit.entity.Player) return "PLAYER";
var type = entity.getType();
return type == null ? "unavailable" : type.name();
}
public void effectEnded(Cast cast, UUID application, String effectId, String reason) {
if (cast == null) return;
emit(cast, "effect.ended", "ended", Map.of("application_id", application.toString(),
"effect_id", effectId, "reason", reason), application);
}
private void emit(Cast cast, String type, String outcome, Map<String, Object> extra) {
emit(cast, type, outcome, extra, null);
}
private void emit(Cast cast, String type, String outcome, Map<String, Object> extra, UUID application) {
try {
var data = new LinkedHashMap<String, Object>(extra);
data.put("action_kind", "ability_cast");
data.put("ability_id", cast.abilityId());
data.put("outcome", outcome);
if (cast.targetId() != null) {
data.put("target_entity_id", cast.targetId().toString());
data.put("target_type", cast.targetType());
if (!cast.targetLocation().isEmpty()) data.put("target_location", cast.targetLocation());
}
var identity = application == null ? cast.actionId() : application;
sink.accept(new EventInput(stable(identity, type), cast.actionId(),
type.equals("action.observed") ? cast.requestedAt() : clock.instant(), type, cast.context(), data));
} catch (RuntimeException ignored) { /* A writer failure cannot authorize or undo gameplay. */ }
}
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 Map<String, Object> locationData(Location value) {
return Map.of("world_id", value.getWorld().getUID().toString(), "world_name", value.getWorld().getName(),
"x", value.getX(), "y", value.getY(), "z", value.getZ(), "captured_at", clock.instant().toString());
}
private static UUID stable(UUID identity, String type) {
return UUID.nameUUIDFromBytes(("fantasystick:ability-cast:" + identity + ":" + type)
.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,61 @@
package games.dmg.fantasystick.events;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.command.BlockCommandSender;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Authorized ability-setting reads and acknowledged updates; no write authority. */
public final class AbilityConfigEventBridge {
public record Actor(String kind, UUID playerId, String playerName) { }
private final Consumer<EventInput> sink;
private final Clock clock;
public AbilityConfigEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
}
/** Capture account identity on the command thread, never on the file worker. */
public Actor capture(CommandSender sender) {
try {
if (sender instanceof Player player) return new Actor("player", player.getUniqueId(), player.getName());
return new Actor(sender instanceof BlockCommandSender ? "command_block" : "console", null, null);
} catch (RuntimeException ignored) {
return null; // Omit uncertain identity, never refuse the authorized action.
}
}
public void read(Actor actor, String abilityId, String key, String value) {
emit("config.read", actor, abilityId, key, null, value);
}
public void changed(Actor actor, String abilityId, String key, String before, String after) {
emit("config.changed", actor, abilityId, key, before, after);
}
private void emit(String type, Actor actor, String abilityId, String key, String before, String after) {
try {
var data = new LinkedHashMap<String, Object>();
data.put("actor_kind", actor.kind());
data.put("ability_id", abilityId);
data.put("key", key);
if (before != null) data.put("before", before);
data.put("after", after);
data.put("application", "future_casts_only");
data.put("outcome", "acknowledged");
var action = UUID.randomUUID();
var id = UUID.nameUUIDFromBytes(("fantasystick:ability-config:" + action + ":" + type)
.getBytes(StandardCharsets.UTF_8));
var context = actor.playerId() == null ? null
: new EventInput.Context(actor.playerId(), actor.playerName(), null, null, null, null);
sink.accept(new EventInput(id, action, clock.instant(), type, context, data));
} catch (RuntimeException ignored) { /* File logging cannot change an authorized configuration action. */ }
}
}
@@ -0,0 +1,54 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.ability.AbilityEngine;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
/** Feature-owned exact ability-refund outcomes; never an authorization or refund authority. */
public final class AbilityEventBridge {
public record Action(UUID actionId,EventInput.Context context,String ability,boolean learn,CharacterRecord before){
public Action{Objects.requireNonNull(actionId);Objects.requireNonNull(context);Objects.requireNonNull(ability);Objects.requireNonNull(before);}
}
private final Consumer<EventInput> sink;private final Clock clock;
public AbilityEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public Action begin(ControlInput input,CharacterRecord before,String ability){return begin(input,before,ability,false);}
public Action begin(ControlInput input,CharacterRecord before,String ability,boolean learn){
var action=new Action(UUID.randomUUID(),context(input.player(),input.session(),before.name()),ability,learn,before);
emit("action.observed",action,"observed",null,null,null);return action;
}
public void refused(org.bukkit.entity.Player player,EquipmentSessions.Active active,CharacterRecord before,String ability,String reason){refused(player,active,before,ability,false,reason);}
public void refused(org.bukkit.entity.Player player,EquipmentSessions.Active active,CharacterRecord before,String ability,boolean learn,String reason){
var action=new Action(UUID.randomUUID(),context(player,active,before.name()),ability,learn,before);emit("action.observed",action,"observed",null,null,null);emit("action.resolved",action,"refused",reason,null,null);
}
public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null,null);}
public void resolved(Action action,AbilityEngine.Mutation mutation){
if(mutation!=null&&mutation.status()==games.dmg.fantasystick.persistence.CharacterStorage.AbilityMutationStatus.APPLIED&&mutation.record()!=null){
emit("action.resolved",action,"accepted","acknowledged",mutation.record(),"acknowledged");
if(action.learn()){emit("ability.learned",action,"accepted","acknowledged",mutation.record(),"acknowledged");emit("ability.assigned",action,"accepted","acknowledged",mutation.record(),"acknowledged");}
else emit("ability.unlearned",action,"accepted","acknowledged",mutation.record(),"acknowledged");
}else emit("action.resolved",action,"refused",mutation==null?"unavailable":mutation.status().name().toLowerCase(java.util.Locale.ROOT),mutation==null?null:mutation.record(),null);
}
private void emit(String type,Action action,String outcome,String reason,CharacterRecord after,String nativePublication){
var before=action.before();var data=new LinkedHashMap<String,Object>();data.put("action_kind",action.learn()?"ability_learn":"ability_unlearn");data.put("ability_id",action.ability());data.put("expected_revision",before.revision().toString());data.put("before_revision",before.revision().toString());data.put("points_before",before.unspentPoints().toString());data.put("outcome",outcome);
var beforeCooldown=before.cooldowns().get(action.ability());if(beforeCooldown!=null)data.put("cooldown_before",beforeCooldown.toString());
if(after!=null){data.put("after_revision",after.revision().toString());data.put("points_after",after.unspentPoints().toString());var afterCooldown=after.cooldowns().get(action.ability());if(afterCooldown!=null)data.put("cooldown_after",afterCooldown.toString());if(action.learn()){data.put("ability_slot",after.loadout().indexOf(action.ability()));data.put("slot_index_base",0);}else{var cleared=new ArrayList<Integer>();for(int slot=0;slot<before.loadout().size();slot++)if(action.ability().equals(before.loadout().get(slot))&&(slot>=after.loadout().size()||!action.ability().equals(after.loadout().get(slot))))cleared.add(slot);data.put("cleared_slot_indexes",cleared);data.put("slot_index_base",0);}}
if(nativePublication!=null){data.put("native_publication",nativePublication);if(!action.learn())data.put("owned_cleanup","invalidation_acknowledged_close_attempted");}
if(reason!=null)data.put(type.startsWith("ability.")?"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(org.bukkit.entity.Player player,EquipmentSessions.Active active,String characterName){return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),characterName,active.session(),location(player.getLocation()));}
private EventInput.Location location(Location location){return new EventInput.Location(location.getWorld().getUID(),location.getWorld().getName(),location.getX(),location.getY(),location.getZ(),clock.instant(),"exact");}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:ability-mutation:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
}
@@ -0,0 +1,26 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.progression.CoinRules;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
/** Failure-isolated character coin initialization, final-death loss and configuration records. */
public final class CoinEventBridge {
private final Consumer<EventInput> sink;private final Clock clock;
public CoinEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public void initialized(CharacterRecord record,String source,CoinRules rules){var operation=stable(record.id(),"coins-initialized");var data=new LinkedHashMap<String,Object>();data.put("operation_id",operation.toString());data.put("character_id",record.id().toString());data.put("character_name",record.name());data.put("source",source);data.put("configured_minimum",Long.toString(rules.startingMin()));data.put("configured_maximum",Long.toString(rules.startingMax()));data.put("balance",record.coins().toString());data.put("revision",record.revision().toString());data.put("outcome","acknowledged");emit(operation,"character.coins_initialized",data);}
public void lost(CharacterRecord before,CharacterRecord after,UUID death,Instant diedAt){var data=new LinkedHashMap<String,Object>();data.put("operation_id",death.toString());data.put("death_id",death.toString());data.put("character_id",after.id().toString());data.put("character_name",after.name());data.put("reason","player_attributed_final_death");data.put("balance_before",before.coins().toString());data.put("balance_after",after.coins().toString());data.put("death_bound_escrow",after.pvpCoinEscrow().toString());data.put("before_revision",before.revision().toString());data.put("after_revision",after.revision().toString());data.put("final_death_at",diedAt.toString());data.put("outcome","acknowledged");emit(death,"character.coins_lost",data);}
public void configRead(String actorKind,UUID player,String name,String key,String value){config("config.read",actorKind,player,name,key,null,value);}
public void configChanged(String actorKind,UUID player,String name,String key,String before,String after){config("config.changed",actorKind,player,name,key,before,after);}
private void config(String type,String actorKind,UUID player,String name,String key,String before,String after){var action=UUID.randomUUID();var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(name!=null)data.put("actor_player_name",name);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application",key.startsWith("starting-")?"future_character_initialization":"future_mob_defeats");data.put("outcome","acknowledged");try{sink.accept(new EventInput(id(action,type),action,clock.instant(),type,player==null?null:new EventInput.Context(player,name,null,null,null,null),Map.copyOf(data)));}catch(RuntimeException ignored){}}
private void emit(UUID action,String type,Map<String,Object> data){try{sink.accept(new EventInput(id(action,type),action,clock.instant(),type,null,Map.copyOf(data)));}catch(RuntimeException ignored){}}
private static UUID stable(UUID character,String kind){return UUID.nameUUIDFromBytes(("fantasystick:coin:"+character+":"+kind).getBytes(StandardCharsets.UTF_8));}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:coin:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
}
@@ -0,0 +1,39 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.CombatRewardRules;
import games.dmg.fantasystick.progression.ManagedEncounterOrigin;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.entity.Player;
/** Failure-isolated, single-owner recipient resolution for combat XP and coin shares. */
public final class CombatRewardEventBridge {
public record Capture(UUID targetId,String targetType,String experienceClass,String coinClass,
CombatRewardRules.KillPosition killPosition,ManagedEncounterOrigin managedOrigin,
String stage,Instant defeatedAt,java.util.List<UUID> directContributors,
String rewardUnitId,int rewardUnitCount,BigInteger experiencePool,BigInteger coinPool){
public Capture{Objects.requireNonNull(targetId);Objects.requireNonNull(targetType);Objects.requireNonNull(experienceClass);Objects.requireNonNull(coinClass);Objects.requireNonNull(stage);Objects.requireNonNull(defeatedAt);directContributors=java.util.List.copyOf(directContributors);Objects.requireNonNull(rewardUnitId);Objects.requireNonNull(experiencePool);Objects.requireNonNull(coinPool);if(rewardUnitCount<1||experiencePool.signum()<0||coinPool.signum()<0)throw new IllegalArgumentException("Invalid combat reward capture");}
}
private final Consumer<EventInput> sink;private final Clock clock;
public CombatRewardEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public void resolved(UUID operation,UUID character,BigInteger allocatedExperience,BigInteger allocatedCoins,
CombatRewardRules.KillPosition restricted,CombatRewardRules rules,Capture capture,
CharacterRecord before,CharacterStorage.CombatAward award,EquipmentSessions.Active active,
Player player,String nativeSync,String feedback){
if(award==null||award.record().isEmpty()||before==null)return;var after=award.record().orElseThrow();var data=new LinkedHashMap<String,Object>();data.put("operation_id",operation.toString());data.put("reward_receipt",operation.toString());data.put("stage",capture.stage());data.put("recipient_character_id",character.toString());data.put("target_id",capture.targetId().toString());data.put("target_type",capture.targetType());data.put("experience_class",capture.experienceClass());data.put("coin_class",capture.coinClass());data.put("defeated_at",capture.defeatedAt().toString());data.put("direct_contributor_character_ids",capture.directContributors().stream().map(UUID::toString).sorted().toList());data.put("reward_unit_id",capture.rewardUnitId());data.put("reward_unit_count",capture.rewardUnitCount());data.put("experience_pool",capture.experiencePool().toString());data.put("coin_pool",capture.coinPool().toString());if(capture.killPosition()!=null)data.put("kill_location",position(capture.killPosition()));data.put("allocated_experience",allocatedExperience.toString());data.put("allocated_coins",allocatedCoins.toString());var appliedExperience=after.experience().subtract(before.experience()).max(BigInteger.ZERO);var appliedCoins=after.coins().subtract(before.coins()).max(BigInteger.ZERO);data.put("applied_experience",appliedExperience.toString());data.put("applied_coins",appliedCoins.toString());data.put("suppressed_experience",allocatedExperience.subtract(appliedExperience.min(allocatedExperience)).toString());data.put("suppressed_coins",allocatedCoins.subtract(appliedCoins.min(allocatedCoins)).toString());data.put("before_revision",before.revision().toString());data.put("after_revision",after.revision().toString());data.put("before_level",before.level());data.put("after_level",after.level());data.put("before_experience",before.experience().toString());data.put("after_experience",after.experience().toString());data.put("before_points",before.unspentPoints().toString());data.put("after_points",after.unspentPoints().toString());data.put("balance_before",before.coins().toString());data.put("balance_after",after.coins().toString());data.put("storage_status",award.status().name().toLowerCase(java.util.Locale.ROOT));data.put("levels_gained",award.levels());data.put("native_sync",nativeSync);data.put("feedback",feedback);if(capture.managedOrigin()!=null){data.put("resolution","managed_origin_exempt");data.put("managed_origin_kind",capture.managedOrigin().kind().name().toLowerCase(java.util.Locale.ROOT));data.put("managed_origin_source",capture.managedOrigin().source().toString());data.put("spacing_applicability","authenticated_managed_origin_exempt");}else if(restricted!=null){boolean blocked=award.status()==CharacterStorage.CombatAwardStatus.SPACING_BLOCKED;data.put("resolution",blocked?"ordinary_spacing_blocked":"ordinary_spacing_eligible");data.put("spacing_applicability","ordinary_restricted_kill");data.put("spacing_minimum",rules.restrictedSpacing().toPlainString());var prior=before.restrictedKills().get(restricted.world());if(prior!=null)data.put("ordinary_history_before",position(prior));var current=after.restrictedKills().get(restricted.world());if(current!=null)data.put("ordinary_history_after",position(current));}else{data.put("resolution","spacing_not_applicable");data.put("spacing_applicability","passive_or_fantasy_character");}data.put("loss_reason",loss(award.status()));var context=context(active,player,after.name());try{sink.accept(new EventInput(id(operation),operation,clock.instant(),"combat.reward_resolved",context,Map.copyOf(data)));}catch(RuntimeException ignored){}
}
private EventInput.Context context(EquipmentSessions.Active active,Player player,String characterName){if(active==null||player==null)return null;var location=player.getLocation();if(location==null||location.getWorld()==null)return new EventInput.Context(active.player(),player.getName(),active.character(),characterName,active.session(),null);return new EventInput.Context(active.player(),player.getName(),active.character(),characterName,active.session(),new EventInput.Location(location.getWorld().getUID(),location.getWorld().getName(),location.getX(),location.getY(),location.getZ(),clock.instant(),"exact"));}
private static Map<String,Object> position(CombatRewardRules.KillPosition value){return Map.of("world_id",value.world().toString(),"x",value.x().toPlainString(),"y",value.y().toPlainString(),"z",value.z().toPlainString());}
private static String loss(CharacterStorage.CombatAwardStatus status){return switch(status){case SPACING_BLOCKED->"restricted_kill_spacing";case AT_CAP->"maximum_level";case UNCHANGED->"zero_experience_and_coins";case DUPLICATE->"duplicate_receipt";case UNAVAILABLE->"recipient_unavailable";case STALE->"stale_retry";case APPLIED->"none";};}
private static UUID id(UUID operation){return UUID.nameUUIDFromBytes(("fantasystick:combat-reward:"+operation).getBytes(StandardCharsets.UTF_8));}
}
@@ -113,6 +113,18 @@ public final class DownedEventBridge {
"character.died", data);
}
/** Physical return is a separate observed native result, never proof of final-death commitment or Tail delivery. */
public void returnAttempted(DownedEpisode episode, String outcome) {
if (episode.outcome() != DownedEpisode.Outcome.FINAL_DEATH) return;
var data = common(episode);
data.put("action_kind", "final_death_return");
data.put("death_id", episode.id().toString());
data.put("effective_death_time", episode.resolvedAt());
data.put("stage", "native_return_attempt");
data.put("outcome", outcome);
emit(stable(episode.id(), "return"), episode.outcomeOperation(), clock.instant(), "action.resolved", data);
}
public void outcomePublished(DownedEpisode episode) {
if(episode.decision()!=null&&"phoenix_down".equals(episode.decision().source()))resolved(episode);
var data = common(episode);
@@ -122,8 +134,11 @@ public final class DownedEventBridge {
data.put("outcome", "completed");
emit(stable(episode.id(), "published"), episode.outcomeOperation(), episode.resolvedAt(),
"action.resolved", data);
if(episode.outcome()==DownedEpisode.Outcome.REVIVED&&episode.protectionUntil()!=null&&episode.decision()!=null&&"revive".equals(episode.decision().source()))protectionApplied(episode);
}
private void protectionApplied(DownedEpisode episode){var data=common(episode);data.put("effect_id","revive_downed_invulnerability");data.put("application_id",stable(episode.id(),"revive-protection").toString());data.put("stage","native_application_acknowledged");data.put("outcome","applied");data.put("protection_until",episode.protectionUntil());data.put("end_policy","persisted_absolute_deadline");emit(stable(episode.id(),"protection-applied"),episode.outcomeOperation(),episode.resolvedAt(),"effect.applied",data);}
public void prevented(DownedEpisode episode, String reason, double amount) {
if (!Double.isFinite(amount) || amount < 0) return;
var now = clock.instant();
@@ -39,6 +39,12 @@ public final class EventCaptureListener implements Listener {
private final EquipmentSessions sessions;
private final Clock clock;
private final Map<Object, Captured> captured = java.util.Collections.synchronizedMap(new WeakHashMap<>());
private java.util.function.Predicate<org.bukkit.event.entity.EntityDamageEvent> ownedDamage = ignored -> false;
/** Feature owners replace the generic incoming view with one richer application record. */
public void excludeOwnedDamage(java.util.function.Predicate<org.bukkit.event.entity.EntityDamageEvent> owner) {
ownedDamage = ownedDamage.or(Objects.requireNonNull(owner));
}
public EventCaptureListener(LocalEventLog events, EquipmentSessions sessions, Clock clock) {
this.events = Objects.requireNonNull(events);
@@ -97,6 +103,32 @@ public final class EventCaptureListener implements Listener {
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(org.bukkit.event.block.BlockDispenseArmorEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(io.papermc.paper.event.player.PrePlayerAttackEntityEvent event){var details=new LinkedHashMap<String,Object>();details.put("entry_point","PrePlayerAttackEntityEvent");details.put("target_id",event.getAttacked().getUniqueId().toString());details.put("will_attack",event.willAttack());details.putAll(item(event.getPlayer().getInventory().getItemInMainHand()));capture(event,event.getPlayer(),"attack_entity",details,event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(io.papermc.paper.event.player.PrePlayerAttackEntityEvent event){resolveCaptured(event,event);}
/** Observe the final accepted native damage event once; no damage or health mutation occurs here. */
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)
public void acceptedDamage(org.bukkit.event.entity.EntityDamageEvent event) {
if (!(event.getEntity() instanceof Player player) || !Double.isFinite(event.getFinalDamage())
|| event.getFinalDamage() <= 0) return;
try { if (ownedDamage.test(event)) return; }
catch (RuntimeException ignored) { /* Observation failure cannot change native damage. */ }
var snapshot = context(player).orElse(null);
if (snapshot == null) return;
var details = new LinkedHashMap<String, Object>();
details.put("target_player_id", player.getUniqueId().toString());
details.put("target_character_id", snapshot.characterId().toString());
details.put("cause", event.getCause().name());
details.put("amount", Double.toString(event.getFinalDamage()));
details.put("outcome", "accepted_native_event");
details.put("cancelled", false);
if (event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent by) {
details.put("source_entity_id", by.getDamager().getUniqueId().toString());
details.put("source_entity_type", by.getDamager().getType().name());
}
try {
var operation = UUID.randomUUID();
events.emit(new EventInput(operation, operation, clock.instant(), "combat.damage", snapshot, details));
} catch (RuntimeException ignored) { }
}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerItemHeldEvent event){capture(event,event.getPlayer(),"held_slot",Map.of("entry_point","PlayerItemHeldEvent","previous_slot",event.getPreviousSlot(),"new_slot",event.getNewSlot()),event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(PlayerItemHeldEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerSwapHandItemsEvent event){var details=new LinkedHashMap<String,Object>();details.put("entry_point","PlayerSwapHandItemsEvent");details.putAll(item(event.getOffHandItem()));capture(event,event.getPlayer(),"swap_hands",details,event);}
@@ -337,7 +337,11 @@ public final class LocalEventLog implements AutoCloseable {
data.put("session_id", context.sessionId().toString());
}
if (context.location() != null) data.put("location", location(context.location()));
} else data.put("actor_kind", "system");
} else {
var claimed = data.get("actor_kind");
data.put("actor_kind", "console".equals(claimed) || "command_block".equals(claimed)
? claimed : "system");
}
var envelope = new LinkedHashMap<String, Object>();
envelope.put("specversion", "1.0");
envelope.put("id", input.id().toString());
@@ -0,0 +1,88 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.presentation.CharacterPresentation;
import io.papermc.paper.event.player.AsyncChatEvent;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
/** Observes supported character messages; no recipient/delivery claims or signed-body changes. */
public final class MessageEventBridge implements Listener {
private static final Set<String> PRIVATE = Set.of("msg", "message", "tell", "w", "whisper", "pm", "reply", "r");
private final Function<UUID, CharacterPresentation.View> views;
private final SessionEventBridge sessions;
private final java.util.function.Consumer<EventInput> sink;
private final Clock clock;
public MessageEventBridge(Function<UUID, CharacterPresentation.View> views, SessionEventBridge sessions,
java.util.function.Consumer<EventInput> sink, Clock clock) {
this.views=Objects.requireNonNull(views);this.sessions=Objects.requireNonNull(sessions);
this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);
}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false)
public void chat(AsyncChatEvent event) {
try {
var view=views.apply(event.getPlayer().getUniqueId());
if(view==null)return;
var context=sessions.lastKnown(view.holder(),view.session(),view.name());
if(context==null)return;
var data=new LinkedHashMap<String,Object>();
data.put("channel","public");
data.put("message",event.signedMessage().message());
data.put("asynchronous",event.isAsynchronous());
data.put("cancelled",event.isCancelled());
data.put("outcome",event.isCancelled()?"cancelled":"observed");
data.put("delivery","unverified");
data.put("location_quality","last_native_session_capture");
emit("character.chat",context,data);
} catch(RuntimeException ignored) { }
}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false)
public void privateMessage(PlayerCommandPreprocessEvent event) {
try {
String command=event.getMessage();
if(command==null||!command.startsWith("/"))return;
int end=command.indexOf(' ');
String label=(end<0?command.substring(1):command.substring(1,end)).toLowerCase(Locale.ROOT);
label=label.substring(label.lastIndexOf(':')+1);
if(!PRIVATE.contains(label))return;
var view=views.apply(event.getPlayer().getUniqueId());
if(view==null)return;
var cached=sessions.lastKnown(view.holder(),view.session(),view.name());
if(cached==null)return;
var position=event.getPlayer().getLocation();
var context=new EventInput.Context(cached.playerId(),cached.playerName(),view.character(),view.name(),view.session(),
new EventInput.Location(position.getWorld().getUID(),position.getWorld().getName(),
position.getX(),position.getY(),position.getZ(),clock.instant(),"native_capture"));
boolean reply=label.equals("reply")||label.equals("r");
String remainder=end<0?"":command.substring(end+1);
var data=new LinkedHashMap<String,Object>();
data.put("command",label);data.put("reply",reply);
if(reply)data.put("message",remainder);
else {
int separator=remainder.indexOf(' ');
if(!remainder.isEmpty())data.put("recipient_token",separator<0?remainder:remainder.substring(0,separator));
data.put("message",separator<0?"":remainder.substring(separator+1));
}
data.put("cancelled",event.isCancelled());
data.put("outcome",event.isCancelled()?"cancelled":"observed");
data.put("delivery","unverified");
emit("character.private_message",context,data);
} catch(RuntimeException ignored) { }
}
private void emit(String type,EventInput.Context context,java.util.Map<String,Object> data) {
var action=UUID.randomUUID();
sink.accept(new EventInput(action,action,clock.instant(),type,context,data));
}
}
@@ -0,0 +1,52 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.party.CharacterParty;
import games.dmg.fantasystick.party.CharacterPartyDirectory;
import games.dmg.fantasystick.party.PartyRegistrar;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** Failure-isolated Registrar and persistent character-party mutation records. */
public final class PartyEventBridge {
public record Action(UUID id,EventInput.Context context,PartyRegistrar registrar,String kind,UUID target,EventInput.Context targetContext,UUID invitation,
CharacterParty before,BigInteger charge,String reason){
public Action{Objects.requireNonNull(id);Objects.requireNonNull(context);Objects.requireNonNull(registrar);Objects.requireNonNull(kind);if(charge==null)charge=BigInteger.ZERO;if(reason==null)reason="requested";}
public Action(UUID id,EventInput.Context context,PartyRegistrar registrar,String kind,UUID target,UUID invitation,CharacterParty before,BigInteger charge){this(id,context,registrar,kind,target,null,invitation,before,charge,"requested");}
}
private final Consumer<EventInput> sink;private final Clock clock;
public PartyEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public Action begin(Player player,games.dmg.fantasystick.session.EquipmentSessions.Active active,String characterName,PartyRegistrar registrar,String kind,UUID target,EventInput.Context targetContext,UUID invitation,CharacterParty before,BigInteger charge,UUID operation,String reason){var action=new Action(operation==null?stableAction(active.character(),kind,target,invitation,before):operation,context(player,active,characterName),registrar,kind,target,targetContext,invitation,before,charge,reason);emitAction("action.observed",action,"observed",null,null,null);return action;}
public void failed(Action action,String reason){emitAction("action.resolved",action,"failed",reason,null,null);}
public void refused(Action action,String reason){emitAction("action.resolved",action,"refused",reason,null,null);}
public void resolved(Action action,CharacterPartyDirectory.Result result){if(result==null||result.status()!=CharacterPartyDirectory.Status.APPLIED){refused(action,result==null?"unavailable":result.status().name().toLowerCase(java.util.Locale.ROOT));return;}resolved(action,result.party().orElse(null),result.invitation().orElse(null));}
public void resolved(Action action,games.dmg.fantasystick.party.PartyManagement.Outcome outcome){if(outcome==null||outcome.status()!=games.dmg.fantasystick.party.PartyManagement.Status.APPLIED){refused(action,outcome==null?"unavailable":outcome.status().name().toLowerCase(java.util.Locale.ROOT));return;}var after=outcome.party().orElse(null);CharacterPartyDirectory.Offer offer=null;if(after!=null){if("invite".equals(action.kind()))offer=after.invitations().stream().filter(value->value.character().equals(action.target())&&(action.before()==null||action.before().invitations().stream().noneMatch(old->old.id().equals(value.id())))).findFirst().map(value->new CharacterPartyDirectory.Offer(value.id(),after.id(),value.character())).orElse(null);else if(action.invitation()!=null)offer=new CharacterPartyDirectory.Offer(action.invitation(),after.id(),action.target()==null?action.context().characterId():action.target());}resolved(action,after,offer);}
private void resolved(Action action,CharacterParty after,CharacterPartyDirectory.Offer offer){emitAction("action.resolved",action,"accepted","acknowledged",after,offer);var businessAfter="disband".equals(action.kind())||"leader_leave".equals(action.kind())?null:after;emitAction(type(action.kind(),action.before(),businessAfter),action,"accepted","acknowledged",businessAfter,offer);}
public void registrarPlaced(String actorKind,UUID actorPlayer,String actorName,PartyRegistrar registrar,UUID entity,boolean loaded){registrar("party_registrar.placed",actorKind,actorPlayer,actorName,registrar,entity,loaded,"registered_and_reconciled");}
public void registrarRemoved(String actorKind,UUID actorPlayer,String actorName,PartyRegistrar registrar,UUID entity,boolean loaded){registrar("party_registrar.removed",actorKind,actorPlayer,actorName,registrar,entity,loaded,"registration_removed");}
public void finalDefeat(UUID character,CharacterParty before,CharacterPartyDirectory.Defeat outcome){if(before==null||outcome==CharacterPartyDirectory.Defeat.UNCHANGED)return;var action=stable(before.id(),character,before.revision(),"final-death");var data=partyData(before,null);data.put("reason","final_death");data.put("subject_character_id",character.toString());data.put("popup_delivery",outcome==CharacterPartyDirectory.Defeat.PARTY_DISBANDED?"active_former_member_delivery_queued":"not_applicable");data.put("outcome","acknowledged");data.put("operation_id",action.toString());emit(id(action,outcome==CharacterPartyDirectory.Defeat.PARTY_DISBANDED?"party.disbanded":"party.left"),action,clock.instant(),outcome==CharacterPartyDirectory.Defeat.PARTY_DISBANDED?"party.disbanded":"party.left",null,data);}
public void configRead(String actorKind,UUID player,String name,String key,String value){config("config.read",actorKind,player,name,key,null,value);}
public void configChanged(String actorKind,UUID player,String name,String key,String before,String after){config("config.changed",actorKind,player,name,key,before,after);}
private void emitAction(String type,Action action,String outcome,String reason,CharacterParty after,CharacterPartyDirectory.Offer invitation){var data=partyData(action.before(),after);data.put("action_kind","party_"+action.kind());data.put("operation_id",action.id().toString());data.put("registrar_id",action.registrar().id().toString());data.put("registrar_location",registrarLocation(action.registrar()));data.put("actor_character_id",action.context().characterId().toString());data.put("actor_player_id",action.context().playerId().toString());data.put("actor_session_id",action.context().sessionId().toString());if(action.target()!=null)data.put("target_character_id",action.target().toString());if(action.targetContext()!=null){data.put("target_player_id",action.targetContext().playerId().toString());data.put("target_session_id",action.targetContext().sessionId().toString());if(action.targetContext().playerName()!=null)data.put("target_player_name",action.targetContext().playerName());if(action.targetContext().location()!=null)data.put("target_location",locationData(action.targetContext().location()));}if(action.invitation()!=null)data.put("invitation_id",action.invitation().toString());else if(invitation!=null)data.put("invitation_id",invitation.id().toString());if(action.charge().signum()>0){data.put("charged_coins",action.charge().toString());data.put("charge_receipt",action.id().toString());}data.put("reason",action.reason());data.put("popup_delivery",type.equals("party.disbanded")?"active_former_member_delivery_queued":"not_applicable");data.put("outcome",outcome);if(reason!=null)data.put(type.startsWith("party.")?"mutation_boundary":"reason",reason);emit(id(action.id(),type),action.id(),clock.instant(),type,action.context(),data);}
private static String type(String kind,CharacterParty before,CharacterParty after){return switch(kind){case "create"->"party.created";case "invite"->"party.invitation_created";case "cancel"->"party.invitation_cancelled";case "decline"->"party.invitation_declined";case "accept"->"party.joined";case "kick"->"party.member_kicked";case "transfer"->"party.leadership_transferred";case "disband","leader_leave"->"party.disbanded";case "leave"->before!=null&&before.leader().equals(before.members().stream().filter(id->after==null||!after.members().contains(id)).findFirst().orElse(null))?"party.disbanded":"party.left";default->"party.mutated";};}
private static LinkedHashMap<String,Object> partyData(CharacterParty before,CharacterParty after){var data=new LinkedHashMap<String,Object>();var party=after==null?before:after;if(party!=null){data.put("party_id",party.id().toString());data.put("party_display_name",party.displayName());data.put("party_canonical_name",party.canonicalName());}if(before!=null){data.put("before_revision",before.revision().toString());data.put("before_leader_character_id",before.leader().toString());data.put("before_member_character_ids",before.members().stream().map(UUID::toString).sorted().toList());}if(after!=null){data.put("after_revision",after.revision().toString());data.put("after_leader_character_id",after.leader().toString());data.put("after_member_character_ids",after.members().stream().map(UUID::toString).sorted().toList());}return data;}
private void registrar(String type,String actorKind,UUID actorPlayer,String actorName,PartyRegistrar registrar,UUID entity,boolean loaded,String outcome){var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(actorPlayer!=null)data.put("actor_player_id",actorPlayer.toString());if(actorName!=null)data.put("actor_player_name",actorName);data.put("registrar_id",registrar.id().toString());data.put("registrar_name",registrar.name());data.put("registrar_revision","0");data.put("operation_id",registrar.id().toString());data.put("registered_location",registrarLocation(registrar));if(entity!=null)data.put("entity_id",entity.toString());data.put("entity_status",loaded?"loaded":"unloaded");data.put("outcome",outcome);emit(id(registrar.id(),type),registrar.id(),clock.instant(),type,actorPlayer==null?null:new EventInput.Context(actorPlayer,actorName,null,null,null,null),data);}
private void config(String type,String actorKind,UUID player,String name,String key,String before,String after){var action=UUID.randomUUID();var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(name!=null)data.put("actor_player_name",name);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application","future_party_transactions");data.put("outcome","acknowledged");emit(id(action,type),action,clock.instant(),type,player==null?null:new EventInput.Context(player,name,null,null,null,null),data);}
private static Map<String,Object> locationData(EventInput.Location value){return 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 static Map<String,Object> registrarLocation(PartyRegistrar value){return Map.of("world_id",value.world().toString(),"x",Double.toString(value.x()),"y",Double.toString(value.y()),"z",Double.toString(value.z()),"yaw",Float.toString(value.yaw()),"pitch",Float.toString(value.pitch()),"quality","registered_exact");}
public EventInput.Context context(Player player,games.dmg.fantasystick.session.EquipmentSessions.Active active,String name){return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),location(player.getLocation()));}
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 stableAction(UUID actor,String kind,UUID target,UUID invitation,CharacterParty before){String party=before==null?"none":before.id().toString(),revision=before==null?"none":before.revision().toString();return UUID.nameUUIDFromBytes(("fantasystick:party-action:"+actor+":"+kind+":"+party+":"+revision+":"+target+":"+invitation).getBytes(StandardCharsets.UTF_8));}
private static UUID stable(UUID party,UUID character,BigInteger revision,String kind){return UUID.nameUUIDFromBytes(("fantasystick:party:"+party+":"+character+":"+revision+":"+kind).getBytes(StandardCharsets.UTF_8));}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:party:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
private void emit(UUID id,UUID action,java.time.Instant time,String type,EventInput.Context context,Map<String,Object> data){try{sink.accept(new EventInput(id,action,time,type,context,Map.copyOf(data)));}catch(RuntimeException ignored){}}
}
@@ -0,0 +1,39 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.passive.PassiveDefinition;
import games.dmg.fantasystick.passive.PassiveEngine;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
/** Feature-owned acknowledged passive mutations and Quicker Regeneration configuration records. */
public final class PassiveEventBridge {
public record Action(UUID actionId,EventInput.Context context,String passive,boolean learn,
PassiveDefinition definition,CharacterRecord before){
public Action{Objects.requireNonNull(actionId);Objects.requireNonNull(context);Objects.requireNonNull(passive);Objects.requireNonNull(definition);Objects.requireNonNull(before);}
}
private final Consumer<EventInput> sink;private final Clock clock;
public PassiveEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public Action begin(ControlInput input,CharacterRecord before,PassiveDefinition definition,boolean learn){var action=new Action(UUID.randomUUID(),context(input.player(),input.session(),before.name()),definition.id(),learn,definition,before);emit("action.observed",action,"observed",null,null);return action;}
public void refused(org.bukkit.entity.Player player,EquipmentSessions.Active active,CharacterRecord before,PassiveDefinition definition,boolean learn,String reason){var action=new Action(UUID.randomUUID(),context(player,active,before.name()),definition.id(),learn,definition,before);emit("action.observed",action,"observed",null,null);emit("action.resolved",action,"refused",reason,null);}
public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null);}
public void resolved(Action action,PassiveEngine.Mutation mutation){if(mutation!=null&&mutation.status()==games.dmg.fantasystick.persistence.CharacterStorage.PassiveMutationStatus.APPLIED&&mutation.record()!=null){emit("action.resolved",action,"accepted","acknowledged",mutation.record());emit(action.learn()?"passive.learned":"passive.unlearned",action,"accepted","acknowledged",mutation.record());}else emit("action.resolved",action,"refused",mutation==null?"unavailable":mutation.status().name().toLowerCase(java.util.Locale.ROOT),mutation==null?null:mutation.record());}
public void configRead(String actorKind,UUID player,String playerName,String key,String value){config("config.read",actorKind,player,playerName,key,null,value);}
public void configChanged(String actorKind,UUID player,String playerName,String key,String before,String after){config("config.changed",actorKind,player,playerName,key,before,after);}
private void emit(String type,Action action,String outcome,String reason,CharacterRecord after){var before=action.before();var data=new LinkedHashMap<String,Object>();data.put("action_kind",action.learn()?"passive_learn":"passive_unlearn");data.put("passive_id",action.passive());data.put("expected_revision",before.revision().toString());data.put("before_revision",before.revision().toString());data.put("points_before",before.unspentPoints().toString());data.put("point_cost",action.definition().pointCost().toString());var requirements=new LinkedHashMap<String,Object>();action.definition().requirements().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry->requirements.put(entry.getKey().name(),entry.getValue().toString()));data.put("stat_prerequisites",requirements);data.put("passive_prerequisites",action.definition().prerequisites().stream().sorted().toList());data.put("outcome",outcome);if(after!=null){data.put("after_revision",after.revision().toString());data.put("points_after",after.unspentPoints().toString());int slot=(action.learn()?after.passives():before.passives()).indexOf(action.passive());data.put("passive_slot",slot);data.put("slot_index_base",0);data.put("native_publication","acknowledged");data.put("owned_cleanup",action.learn()?"passive_state_acknowledged":"passive_state_acknowledged");}if(reason!=null)data.put(type.startsWith("passive.")?"native_boundary":"reason",reason);try{sink.accept(new EventInput(id(action.actionId(),type),action.actionId(),clock.instant(),type,action.context(),Map.copyOf(data)));}catch(RuntimeException ignored){}
}
private void config(String type,String actorKind,UUID player,String playerName,String key,String before,String after){var action=UUID.randomUUID();var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(playerName!=null)data.put("actor_player_name",playerName);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application","future_recovery_pulses");data.put("outcome","acknowledged");var context=player==null?null:new EventInput.Context(player,playerName,null,null,null,null);try{sink.accept(new EventInput(id(action,type),action,clock.instant(),type,context,Map.copyOf(data)));}catch(RuntimeException ignored){}
}
private EventInput.Context context(org.bukkit.entity.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.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:passive:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
}
@@ -0,0 +1,198 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.PlayerFeedbackPreferences;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
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.Function;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** Captured account preferences remain independent of gameplay and feedback delivery. */
public final class PreferenceEventBridge {
public record Action(UUID id, Instant time, EventInput.Context context, String key,
boolean before, boolean savedBefore, Boolean requested, String actorKind) { }
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final Clock clock;
private final EquipmentSessions sessions;
private final PlayerFeedbackPreferences preferences;
private final Consumer<Throwable> failures;
public PreferenceEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink, Clock clock,
EquipmentSessions sessions, PlayerFeedbackPreferences preferences,
Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.sessions = Objects.requireNonNull(sessions);
this.preferences = Objects.requireNonNull(preferences);
this.failures = Objects.requireNonNull(failures);
}
public Action begin(Player player, String key, boolean before, Boolean requested) {
Objects.requireNonNull(player);
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
var where = player.getLocation();
EventInput.Location location = where == null || where.getWorld() == null ? null
: new EventInput.Location(where.getWorld().getUID(), where.getWorld().getName(), where.getX(),
where.getY(), where.getZ(), clock.instant(), "exact");
var action = new Action(UUID.randomUUID(), clock.instant(), new EventInput.Context(player.getUniqueId(),
player.getName(), record == null ? null : record.id(), record == null ? null : record.name(),
record == null ? null : active.session(), location), key, before,
key.equals("player-aura-particles") ? preferences.savedPlayerAurasEnabled(player.getUniqueId())
: key.equals("combat_feedback") ? preferences.savedEnabled(player.getUniqueId())
: preferences.savedAllParticlesEnabled(player.getUniqueId()), requested, "player");
emit("action.observed", action, "observed", null, null, before, false);
return action;
}
public void refuseNonPlayer(org.bukkit.command.CommandSender sender, String key) {
var kind = sender instanceof org.bukkit.command.ConsoleCommandSender ? "console"
: sender instanceof org.bukkit.command.BlockCommandSender ? "command_block" : "system";
boolean initial = !key.equals("player-aura-particles");
var action = new Action(UUID.randomUUID(), clock.instant(), null, key, initial, initial, null, kind);
emit("action.observed", action, "observed", null, null, true, false);
emit("action.resolved", action, "refused", "player_required", null, true, false);
}
/** Written with the preference value in the same acknowledged file revision. */
public PlayerFeedbackPreferences.EventIntent intent(Action action) {
var fields = new LinkedHashMap<String,String>();
fields.put("time", action.time().toString());
fields.put("account", action.context().playerId().toString());
fields.put("account_name", action.context().playerName());
fields.put("key", action.key());
fields.put("before", Boolean.toString(action.before()));
fields.put("saved_before", Boolean.toString(action.savedBefore()));
fields.put("requested", Boolean.toString(action.requested()));
fields.put("all_enabled", Boolean.toString(preferences.allParticlesEnabled(action.context().playerId())));
if (action.context().characterId() != null) {
fields.put("character", action.context().characterId().toString());
fields.put("character_name", action.context().characterName());
fields.put("session", action.context().sessionId().toString());
}
var location = action.context().location();
if (location != null) {
fields.put("world", location.worldId().toString());
fields.put("world_name", location.worldName());
fields.put("x", Double.toString(location.x()));
fields.put("y", Double.toString(location.y()));
fields.put("z", Double.toString(location.z()));
fields.put("location_time", location.capturedAt().toString());
}
return new PlayerFeedbackPreferences.EventIntent(action.id(), fields);
}
public void replay(PlayerFeedbackPreferences.EventIntent intent) {
try {
var row = intent.captured();
UUID account = UUID.fromString(row.get("account"));
if (!"true".equals(row.get("published"))
|| Boolean.parseBoolean(row.getOrDefault("saved_before", row.get("before")))
== Boolean.parseBoolean(row.get("requested"))) return;
UUID character = row.containsKey("character") ? UUID.fromString(row.get("character")) : null;
EventInput.Location location = row.containsKey("world")
? new EventInput.Location(UUID.fromString(row.get("world")), row.get("world_name"),
Double.parseDouble(row.get("x")), Double.parseDouble(row.get("y")), Double.parseDouble(row.get("z")),
Instant.parse(row.get("location_time")), "exact") : null;
var context = new EventInput.Context(account, row.get("account_name"), character,
row.get("character_name"), character == null ? null : UUID.fromString(row.get("session")), location);
var action = new Action(intent.actionId(), Instant.parse(row.get("time")), context, row.get("key"),
Boolean.parseBoolean(row.get("before")), Boolean.parseBoolean(
row.getOrDefault("saved_before", row.get("before"))),
Boolean.parseBoolean(row.get("requested")), "player");
boolean after = Boolean.parseBoolean(row.get("requested"));
boolean effective = row.get("key").equals("player-aura-particles")
? after && Boolean.parseBoolean(row.get("all_enabled")) : after;
emit("preference.changed", action, "accepted", "acknowledged", intent.sequence(), effective, true);
} catch (RuntimeException ignored) { /* Invalid pending evidence cannot authorize a preference change. */ }
}
public void read(Action action) {
emit("action.resolved", action, "accepted", "read", null, action.before(), false);
emit("preference.read", action, "accepted", "acknowledged", null, action.before(), false);
}
public void saved(Action action, PlayerFeedbackPreferences.Update update, boolean published, boolean effective) {
if (!published) {
if (update.intent() != null) preferences.acknowledgeEvent(action.id());
emit("action.resolved", action, "refused", "superseded", update.sequence(), effective, false);
return;
}
if (update.intent() == null || action.savedBefore() == action.requested()) {
if (update.intent() != null) preferences.acknowledgeEvent(action.id());
emit("action.resolved", action, "refused", "unchanged", update.sequence(), effective, false);
return;
}
try {
preferences.markPublished(action.id()).whenComplete((ignored, failure) -> {
if (failure != null) try { failures.accept(failure); } catch (RuntimeException ignoredFailure) { }
});
} catch (RuntimeException failure) {
try { failures.accept(failure); } catch (RuntimeException ignoredFailure) { }
}
emit("action.resolved", action, "accepted", "acknowledged", update.sequence(), effective, false);
emit("preference.changed", action, "accepted", "acknowledged", update.sequence(), effective, true);
}
public void failed(Action action, String reason) {
emit("action.resolved", action, "failed", reason, null, action.before(), false);
}
public void refused(Action action, String reason) {
emit("action.resolved", action, "refused", reason, null, action.before(), false);
}
private void emit(String type, Action action, String outcome, String reason, Long preferenceSequence,
boolean effective, boolean acknowledgedIntent) {
if (action == null) return;
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", "preference_" + (action.requested() == null ? "read" : "change"));
data.put("entry_point", action.key().equals("combat_feedback") ? "/fstick feedback"
: "/fstick particles " + (action.key().equals("player-aura-particles") ? "players" : "all"));
data.put("preference_key", action.key());
if (action.context() == null) { data.put("actor_kind", action.actorKind()); data.put("location_status", "not_applicable"); }
data.put("scope", "account");
data.put("default_value", Boolean.toString(!action.key().equals("player-aura-particles")));
data.put("before", Boolean.toString(action.before()));
data.put("saved_before", Boolean.toString(action.savedBefore()));
data.put("effective_before", Boolean.toString(action.before()));
if (action.requested() != null) data.put("requested", Boolean.toString(action.requested()));
if (action.context() != null) {
boolean latestSaved = type.equals("preference.changed") ? action.requested()
: preferenceSequence == null ? action.savedBefore()
: action.key().equals("player-aura-particles")
? preferences.savedPlayerAurasEnabled(action.context().playerId())
: action.key().equals("combat_feedback")
? preferences.savedEnabled(action.context().playerId())
: preferences.savedAllParticlesEnabled(action.context().playerId());
data.put("saved_value", Boolean.toString(latestSaved));
data.put("effective_value", Boolean.toString(effective));
data.put("effective_after", Boolean.toString(effective));
}
if (preferenceSequence != null) data.put("preference_sequence", Long.toString(preferenceSequence));
data.put("outcome", outcome);
if (reason != null) data.put(type.startsWith("preference.") ? "acknowledgment" : "reason", reason);
if (type.equals("preference.changed")) {
data.put("after", Boolean.toString(action.requested()));
data.put("preference_revision", Long.toString(preferenceSequence));
}
try {
var input = new EventInput(UUID.nameUUIDFromBytes(("fantasystick:preference:"+action.id()+":"+type)
.getBytes(StandardCharsets.UTF_8)), action.id(), type.equals("preference.changed") ? action.time()
: clock.instant(), type, action.context(), Map.copyOf(data));
var result = sink.apply(input);
if (acknowledgedIntent) result.whenComplete((delivery, failure) -> {
if (failure == null && delivery != LocalEventLog.Delivery.REJECTED)
preferences.acknowledgeEvent(action.id());
});
} catch (RuntimeException ignored) { /* Logging cannot affect the command or persistent state. */ }
}
}
@@ -0,0 +1,77 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
/** Feature-owned permanent-stat outcomes, isolated from purchase and native-health authority. */
public final class ProgressionEventBridge {
public record Action(UUID actionId, EventInput.Context context, Stat stat, BigInteger expectedRevision,
BigInteger statBefore, BigInteger pointsBefore, BigDecimal healthBefore,
BigDecimal maximumBefore, BigDecimal nativeLimit) {
public Action {
Objects.requireNonNull(actionId); Objects.requireNonNull(context); Objects.requireNonNull(stat);
Objects.requireNonNull(expectedRevision); Objects.requireNonNull(statBefore); Objects.requireNonNull(pointsBefore);
Objects.requireNonNull(healthBefore); Objects.requireNonNull(maximumBefore); Objects.requireNonNull(nativeLimit);
}
}
private final Consumer<EventInput> sink;
private final Clock clock;
public ProgressionEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);
}
public Action begin(ControlInput input, CharacterRecord before, Stat stat, BigDecimal health,
BigDecimal maximum, BigDecimal nativeLimit) {
Objects.requireNonNull(input);Objects.requireNonNull(before);Objects.requireNonNull(stat);
var action=new Action(UUID.randomUUID(),context(input,before.name()),stat,before.revision(),
before.stats().get(stat),before.unspentPoints(),health,maximum.min(nativeLimit),nativeLimit);
emit("action.observed",action,"observed",null,null,null,null);
return action;
}
public void refused(Action action,String reason,CharacterRecord current){
emit("action.resolved",action,"refused",reason,current,null,null);
}
public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null,null,null);}
public void increased(Action action,CharacterRecord saved,String nativePublication,BigDecimal nativeHealthAfter){
emit("action.resolved",action,"accepted","acknowledged",saved,nativePublication,nativeHealthAfter);
emit("character.stat_increased",action,"accepted","acknowledged",saved,nativePublication,nativeHealthAfter);
}
public void refused(org.bukkit.entity.Player player,games.dmg.fantasystick.session.EquipmentSessions.Active active,
CharacterRecord current,Stat stat,BigDecimal nativeLimit,String reason){
var input=new ControlInput(player,active,0,"",ControlInput.Kind.BOOK,ControlInput.Gesture.USE,null,0,0,()->true);
var action=begin(input,current,stat,BigDecimal.valueOf(player.getHealth()),current.maximumHealth().min(nativeLimit),nativeLimit);
emit("action.resolved",action,"refused",reason,current,null,null);
}
private void emit(String type,Action action,String outcome,String reason,CharacterRecord after,
String nativePublication,BigDecimal nativeHealthAfter){
var data=new LinkedHashMap<String,Object>();data.put("action_kind","character_stat_increase");
data.put("stat_id",action.stat().name());data.put("expected_revision",action.expectedRevision().toString());
data.put("before_revision",action.expectedRevision().toString());data.put("stat_before",action.statBefore().toString());
data.put("points_before",action.pointsBefore().toString());data.put("health_before",action.healthBefore().toPlainString());
data.put("maximum_health_before",action.maximumBefore().toPlainString());data.put("native_health_ceiling",action.nativeLimit().toPlainString());
data.put("outcome",outcome);if(reason!=null)data.put(type.equals("character.stat_increased")?"native_boundary":"reason",reason);
if(after!=null){data.put("after_revision",after.revision().toString());data.put("stat_after",after.stats().get(action.stat()).toString());data.put("points_after",after.unspentPoints().toString());data.put("health_after",(nativeHealthAfter==null?after.health():nativeHealthAfter).toPlainString());data.put("maximum_health_after",after.maximumHealth().min(action.nativeLimit()).toPlainString());}
if(nativePublication!=null)data.put("native_publication",nativePublication);
try{sink.accept(new EventInput(id(action.actionId(),type),action.actionId(),clock.instant(),type,action.context(),Map.copyOf(data)));}catch(RuntimeException ignored){}
}
private EventInput.Context context(ControlInput input,String characterName){var player=input.player();return new EventInput.Context(player.getUniqueId(),player.getName(),input.session().character(),characterName,input.session().session(),location(player.getLocation()));}
private EventInput.Location location(Location location){return new EventInput.Location(location.getWorld().getUID(),location.getWorld().getName(),location.getX(),location.getY(),location.getZ(),clock.instant(),"exact");}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:progression:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
}
@@ -0,0 +1,35 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** One authoritative out-of-combat healing publication with bounded passive attribution. */
public final class RecoveryEventBridge {
public record Action(UUID actionId,EventInput.Context context,BigInteger expectedRevision,
BigDecimal healthBefore,BigDecimal proposed,BigInteger configuredBase,
BigInteger configuredQuickerRegeneration){
public Action{Objects.requireNonNull(actionId);Objects.requireNonNull(context);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(healthBefore);Objects.requireNonNull(proposed);Objects.requireNonNull(configuredBase);Objects.requireNonNull(configuredQuickerRegeneration);}
}
private final Consumer<EventInput> sink;private final Clock clock;
public RecoveryEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public Action begin(Player player,EquipmentSessions.Active active,CharacterRecord record,double current,double proposed,BigInteger base,BigInteger bonus){var action=new Action(UUID.randomUUID(),context(player,active,record.name()),record.revision(),BigDecimal.valueOf(current),BigDecimal.valueOf(proposed),base,bonus);emit("action.observed",action,"observed",null,null,null,null,null);return action;}
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 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.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,40 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.ability.AbilityExecution;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.RevivalStore;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** Learned Revive Tail-branch cast and consume-once restoration evidence. */
public final class ReviveEventBridge {
public record Cast(UUID actionId,EventInput.Context caster,UUID casterCharacter,String casterCharacterName,
UUID targetPlayer,String targetPlayerName,Map<String,Object> targetLocation,
Instant castAt,Instant cooldownUntil,double range){
public Cast{Objects.requireNonNull(actionId);Objects.requireNonNull(caster);Objects.requireNonNull(casterCharacter);Objects.requireNonNull(casterCharacterName);Objects.requireNonNull(targetPlayer);Objects.requireNonNull(targetPlayerName);Objects.requireNonNull(targetLocation);Objects.requireNonNull(castAt);Objects.requireNonNull(cooldownUntil);}
}
private final Consumer<EventInput> sink;private final Clock clock;
public ReviveEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public Cast cast(AbilityExecution execution,Player target){var input=execution.input();var caster=execution.character();var now=clock.instant();var action=new Cast(UUID.randomUUID(),context(input.player(),input.session(),caster.name()),caster.id(),caster.name(),target.getUniqueId(),target.getName(),locationData(target.getLocation(),now),now,caster.cooldowns().get("revive"),input.player().getLocation().distance(target.getLocation()));emitAbility("ability.cast",action,"accepted","cooldown_committed",null);return action;}
public void resolved(Cast cast,String outcome,String stage){emitAbility("ability.resolved",cast,outcome,stage,null);}
public void failed(Cast cast,String reason){emitAbility("ability.resolved",cast,"failed","tail_restoration",reason);}
public void revived(Cast cast,RevivalStore.Reservation reservation,CharacterRecord dead,CharacterRecord restored,Player carrier){revived(cast,reservation,dead,restored,carrier,false);}
public void recovered(RevivalStore.Reservation reservation,CharacterRecord restored,Player carrier){if(reservation.administrative())return;revived(null,reservation,null,restored,carrier,true);}
private void revived(Cast cast,RevivalStore.Reservation reservation,CharacterRecord dead,CharacterRecord restored,Player carrier,boolean recovery){var data=new LinkedHashMap<String,Object>();data.put("revival_kind","learned_tail");data.put("selected_branch","tail");data.put("reservation_id",reservation.id().toString());data.put("death_id",reservation.death().toString());data.put("tail_id",reservation.death().toString());data.put("caster_character_id",reservation.casterCharacter().toString());if(cast!=null){data.put("caster_player_id",cast.caster().playerId().toString());data.put("caster_player_name",cast.caster().playerName());data.put("cast_action_id",cast.actionId().toString());}data.put("carrier_player_id",carrier.getUniqueId().toString());data.put("carrier_player_name",carrier.getName());data.put("source_slot",reservation.sourceSlot());data.put("destination_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("consumed_quantity","1");data.put("restored_character_id",restored.id().toString());data.put("restored_character_name",restored.name());data.put("restored_revision",restored.revision().toString());data.put("restored_health",restored.health().toPlainString());data.put("maximum_health",restored.maximumHealth().toPlainString());data.put("no_auto_entry",true);data.put("recovery_reconciliation",recovery);data.put("outcome","acknowledged");data.put("carrier_location",locationData(carrier.getLocation(),clock.instant()));var context=new EventInput.Context(carrier.getUniqueId(),carrier.getName(),null,null,null,location(carrier.getLocation()));emit(id(reservation.id(),"character-revived"),reservation.id(),reservation.createdAt(),"character.revived",context,data);if(cast!=null)emitAbility("ability.resolved",cast,"restored","tail_restoration_acknowledged",null,Map.of("reservation_id",reservation.id().toString(),"death_id",reservation.death().toString(),"restored_character_id",restored.id().toString()));}
private void emitAbility(String type,Cast cast,String outcome,String stage,String reason){emitAbility(type,cast,outcome,stage,reason,Map.of());}
private void emitAbility(String type,Cast cast,String outcome,String stage,String reason,Map<String,Object> extra){var data=new LinkedHashMap<String,Object>();data.put("ability_id","revive");data.put("selected_branch","tail");data.put("stage",stage);data.put("outcome",outcome);data.put("cooldown_until",cast.cooldownUntil());data.put("caster_character_id",cast.casterCharacter().toString());data.put("target_player_id",cast.targetPlayer().toString());data.put("target_player_name",cast.targetPlayerName());data.put("target_location",cast.targetLocation());data.put("final_range",Double.toString(cast.range()));data.put("line_of_sight",true);if(reason!=null)data.put("reason",reason);data.putAll(extra);emit(id(cast.actionId(),type),cast.actionId(),cast.castAt(),type,cast.caster(),data);}
private EventInput.Context context(Player player,games.dmg.fantasystick.session.EquipmentSessions.Active active,String name){return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),location(player.getLocation()));}
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 Map<String,Object> locationData(Location value,Instant at){return Map.of("world_id",value.getWorld().getUID().toString(),"world_name",value.getWorld().getName(),"x",Double.toString(value.getX()),"y",Double.toString(value.getY()),"z",Double.toString(value.getZ()),"captured_at",at,"quality","exact");}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:revive:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
private void emit(UUID id,UUID action,Instant time,String type,EventInput.Context context,Map<String,Object> data){try{sink.accept(new EventInput(id,action,time,type,context,Map.copyOf(data)));}catch(RuntimeException ignored){}}
}
@@ -0,0 +1,99 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** Observes acknowledged session boundaries; the log never authorizes a transition. */
public final class SessionEventBridge {
private final Consumer<EventInput> sink;
private final Clock clock;
private final java.util.concurrent.ConcurrentHashMap<UUID, EventInput.Context> current = new java.util.concurrent.ConcurrentHashMap<>();
public SessionEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
}
public void entered(Player player, UUID session, CharacterRecord record, Location entry,
boolean resumedDowned, boolean adventureMode) {
try {
var now = clock.instant();
var data = new LinkedHashMap<String, Object>();
data.put("session_id", session.toString());
data.put("character_id", record.id().toString());
data.put("revision", record.revision().toString());
data.put("entry_location", locationMap(entry));
data.put("resumed_downed", resumedDowned);
data.put("adventure_mode_applied", adventureMode);
data.put("equipment_outcome", "packed");
data.put("session_receipt", "acknowledged");
data.put("outcome", "active");
var context = new EventInput.Context(player.getUniqueId(), player.getName(), record.id(), record.name(),
session, location(player.getLocation()));
current.put(context.playerId(), context);
sink.accept(new EventInput(id(session, "entered"), session, now, "character.entered", context, data));
} catch (RuntimeException ignored) {
// An unavailable journal cannot change the acknowledged session.
}
}
/** Thread-safe last native capture, never a live Bukkit lookup from asynchronous chat. */
public EventInput.Context lastKnown(UUID player, UUID session, String characterName) {
var value = current.get(player);
if (value == null || !value.sessionId().equals(session)) return null;
return new EventInput.Context(value.playerId(), value.playerName(), value.characterId(), characterName,
session, value.location());
}
public void forget(UUID player, UUID session) {
var value=current.get(player);
if(value!=null&&value.sessionId().equals(session))current.remove(player,value);
}
public record ExitCapture(EventInput.Context context, java.util.Map<String, Object> entryLocation, String revision) { }
public ExitCapture captureExit(Player player, UUID session, CharacterRecord record, Location entry) {
try {
return new ExitCapture(new EventInput.Context(player.getUniqueId(), player.getName(), record.id(),
record.name(), session, location(player.getLocation())), locationMap(entry), record.revision().toString());
} catch (RuntimeException ignored) { return null; }
}
public void exited(UUID session, ExitCapture capture) {
if (capture == null) return;
try {
var data = new LinkedHashMap<String, Object>();
data.put("session_id", session.toString());
data.put("character_id", capture.context().characterId().toString());
data.put("revision", capture.revision());
data.put("entry_location", capture.entryLocation());
data.put("reason", "living_exit");
data.put("equipment_outcome", "restored");
data.put("session_receipt", "completed");
data.put("outcome", "acknowledged");
forget(capture.context().playerId(),session);
sink.accept(new EventInput(id(session, "exited"), session, clock.instant(), "character.exited", capture.context(), data));
} catch (RuntimeException ignored) { }
}
private java.util.Map<String, Object> locationMap(Location value) {
return java.util.Map.of("world_id", value.getWorld().getUID().toString(), "world_name", value.getWorld().getName(),
"x", value.getX(), "y", value.getY(), "z", value.getZ(), "captured_at", clock.instant().toString());
}
private EventInput.Location location(Location value) {
return new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(),
value.getY(), value.getZ(), clock.instant(), "native_capture");
}
private static UUID id(UUID session, String stage) {
return UUID.nameUUIDFromBytes(("fantasystick:session:" + session + ':' + stage).getBytes(StandardCharsets.UTF_8));
}
}
@@ -79,6 +79,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
private java.util.function.Predicate<org.bukkit.entity.Entity> excluded = ignored -> false;
private java.util.function.Function<LivingEntity,java.util.Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin>> managedOrigins=ignored->java.util.Optional.empty();
private Consumer<games.dmg.fantasystick.progression.ManagedEncounterOrigin> managedSettlement=ignored->{};
private games.dmg.fantasystick.events.CombatRewardEventBridge events;
private boolean closed;
private final class PendingAward {
@@ -88,12 +89,14 @@ public final class CombatExperience implements Listener, AutoCloseable {
final BigInteger coins;
final CombatRewardRules.KillPosition restricted;
final CombatRewardRules rewardRules;
BigInteger expectedRevision;
final games.dmg.fantasystick.events.CombatRewardEventBridge.Capture capture;
BigInteger expectedRevision;CharacterRecord before;
boolean running;final CompletableFuture<Void> settled=new CompletableFuture<>();
PendingAward(UUID id, UUID character, BigInteger experience, BigInteger coins,
CombatRewardRules.KillPosition restricted, CombatRewardRules rewardRules) {
CombatRewardRules.KillPosition restricted, CombatRewardRules rewardRules,
games.dmg.fantasystick.events.CombatRewardEventBridge.Capture capture) {
this.id = id; this.character = character; this.experience = experience;
this.coins = coins; this.restricted = restricted; this.rewardRules = rewardRules;
this.coins = coins; this.restricted = restricted; this.rewardRules = rewardRules;this.capture=Objects.requireNonNull(capture);
}
}
@@ -140,6 +143,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
if(closed)throw new IllegalStateException("Combat experience is closed");managedOrigins=Objects.requireNonNull(classifier);
}
public void attachManagedSettlement(Consumer<games.dmg.fantasystick.progression.ManagedEncounterOrigin> settlement){if(closed)throw new IllegalStateException("Combat experience is closed");managedSettlement=Objects.requireNonNull(settlement);}
public void attachEvents(games.dmg.fantasystick.events.CombatRewardEventBridge value){if(closed||events!=null)throw new IllegalStateException("Combat reward events already attached or combat is closed");events=Objects.requireNonNull(value);}
public void install(org.bukkit.plugin.Plugin plugin) {
if (closed || retryTask != null) throw new IllegalStateException("Combat experience is already installed or closed");
@@ -207,12 +211,12 @@ public final class CombatExperience implements Listener, AutoCloseable {
/** Freeze final-time eligibility and exact shares before the durable death claim. */
public List<games.dmg.fantasystick.session.DownedEpisode.Reward> allocateDownedDefeat(games.dmg.fantasystick.session.DownedEpisode episode){
var attribution=episode.attribution();if(!attribution.playerDefeat()||attribution.contributors().isEmpty())return List.of();var units=rewardUnits(new ArrayList<>(attribution.contributors()));if(units.isEmpty())return List.of();var capturedRewards=rewards;var replaySafeRandom=new java.util.Random(episode.id().getMostSignificantBits()^episode.id().getLeastSignificantBits());var xp=games.dmg.fantasystick.progression.RewardAllocator.allocate(capturedRewards.pool(CombatRewardRules.Target.FANTASY_CHARACTER,attribution.victimLevel()),units,replaySafeRandom);var coins=games.dmg.fantasystick.progression.RewardAllocator.allocate(attribution.victimCoins(),units,replaySafeRandom);return xp.keySet().stream().sorted().map(character->new games.dmg.fantasystick.session.DownedEpisode.Reward(character,xp.get(character),coins.get(character))).toList();
var attribution=episode.attribution();if(!attribution.playerDefeat()||attribution.contributors().isEmpty())return List.of();var units=rewardUnits(new ArrayList<>(attribution.contributors()));if(units.isEmpty())return List.of();var capturedRewards=rewards;var replaySafeRandom=new java.util.Random(episode.id().getMostSignificantBits()^episode.id().getLeastSignificantBits());var experiencePool=capturedRewards.pool(CombatRewardRules.Target.FANTASY_CHARACTER,attribution.victimLevel());var xp=games.dmg.fantasystick.progression.RewardAllocator.allocate(experiencePool,units,replaySafeRandom);var coins=games.dmg.fantasystick.progression.RewardAllocator.allocate(attribution.victimCoins(),units,replaySafeRandom);return xp.keySet().stream().sorted().map(character->{var unit=units.stream().filter(value->value.recipients().contains(character)).findFirst().orElseThrow();return new games.dmg.fantasystick.session.DownedEpisode.Reward(character,xp.get(character),coins.get(character),String.valueOf(unit.key()),units.size());}).toList();
}
/** Apply only the shares frozen in the winning durable final-death claim. */
public CompletionStage<Void> settleDownedDefeat(games.dmg.fantasystick.session.DownedEpisode episode){
if(episode.finalRewards().isEmpty())return CompletableFuture.completedStage(null);var capturedRewards=rewards;var settlements=new ArrayList<CompletableFuture<Void>>();for(var reward:episode.finalRewards()){UUID receipt=UUID.nameUUIDFromBytes(("downed:"+episode.id()+":"+reward.character()).getBytes(java.nio.charset.StandardCharsets.UTF_8));var proposed=new PendingAward(receipt,reward.character(),reward.experience(),reward.coins(),null,capturedRewards);var existing=pending.putIfAbsent(receipt,proposed);var operation=existing==null?proposed:existing;settlements.add(operation.settled);attempt(operation);}return CompletableFuture.allOf(settlements.toArray(CompletableFuture[]::new));
if(episode.finalRewards().isEmpty())return CompletableFuture.completedStage(null);var capturedRewards=rewards;var settlements=new ArrayList<CompletableFuture<Void>>();for(var reward:episode.finalRewards()){UUID receipt=UUID.nameUUIDFromBytes(("downed:"+episode.id()+":"+reward.character()).getBytes(java.nio.charset.StandardCharsets.UTF_8));var capture=new games.dmg.fantasystick.events.CombatRewardEventBridge.Capture(episode.player(),"player","fantasy_character","pvp_balance",null,null,"final_death",episode.resolvedAt(),episode.attribution().contributors(),reward.unitId(),reward.unitCount(),capturedRewards.pool(CombatRewardRules.Target.FANTASY_CHARACTER,episode.attribution().victimLevel()),episode.attribution().victimCoins());var proposed=new PendingAward(receipt,reward.character(),reward.experience(),reward.coins(),null,capturedRewards,capture);var existing=pending.putIfAbsent(receipt,proposed);var operation=existing==null?proposed:existing;settlements.add(operation.settled);attempt(operation);}return CompletableFuture.allOf(settlements.toArray(CompletableFuture[]::new));
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -240,7 +244,8 @@ public final class CombatExperience implements Listener, AutoCloseable {
? coinRules.get().mob(coinTarget(target), random) : defeatedCoins;
if (units.isEmpty()) {managed.ifPresent(this::settled);return;} // The defeat transition has already persisted any PvP balance loss.
var capturedRewards = rewards;
var xp = games.dmg.fantasystick.progression.RewardAllocator.allocate(capturedRewards.pool(kind, level), units, random);
var experiencePool=capturedRewards.pool(kind,level);
var xp = games.dmg.fantasystick.progression.RewardAllocator.allocate(experiencePool, units, random);
var coins = games.dmg.fantasystick.progression.RewardAllocator.allocate(coinPool, units, random);
CombatRewardRules.KillPosition restricted = managed.isPresent()||kind == CombatRewardRules.Target.PASSIVE
|| kind == CombatRewardRules.Target.FANTASY_CHARACTER ? null : position(target.getLocation());
@@ -250,7 +255,8 @@ public final class CombatExperience implements Listener, AutoCloseable {
for (UUID character : xp.keySet()) {
String receiptAuthority=managed.isPresent()?authority:targetReward+":"+now+":"+authority;
UUID receipt = UUID.nameUUIDFromBytes((receiptAuthority+":"+character).getBytes(java.nio.charset.StandardCharsets.UTF_8));
var proposed = new PendingAward(receipt, character, xp.get(character), coins.get(character), restricted,capturedRewards);
var unit=units.stream().filter(value->value.recipients().contains(character)).findFirst().orElseThrow();var capture=new games.dmg.fantasystick.events.CombatRewardEventBridge.Capture(target.getUniqueId(),target.getType().name().toLowerCase(java.util.Locale.ROOT),kind.name().toLowerCase(java.util.Locale.ROOT),coinTarget(target).name().toLowerCase(java.util.Locale.ROOT),defeatedCharacter==null?position(target.getLocation()):null,managed.orElse(null),defeatedCharacter==null?"mob_defeat":"fantasy_character_defeat",now,direct,String.valueOf(unit.key()),units.size(),experiencePool,coinPool);
var proposed = new PendingAward(receipt, character, xp.get(character), coins.get(character), restricted,capturedRewards,capture);
var existing=pending.putIfAbsent(receipt,proposed);var operation=existing==null?proposed:existing;settlements.add(operation.settled);attempt(operation);
}
managed.ifPresent(origin->CompletableFuture.allOf(settlements.toArray(CompletableFuture[]::new)).thenRun(()->settled(origin)));
@@ -304,7 +310,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
synchronized (operation) { operation.running = false; }
if (failure != null) { fail(operation, failure); return; }
if (record.isEmpty() || record.orElseThrow().dead()) { complete(operation); return; }
operation.expectedRevision = record.orElseThrow().revision();
operation.before=record.orElseThrow();operation.expectedRevision = operation.before.revision();
attempt(operation);
});
return;
@@ -314,7 +320,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
synchronized (operation) { operation.running = false; }
if (failure != null) { fail(operation, failure); return; }
if (award.status() == CharacterStorage.CombatAwardStatus.STALE && award.record().isPresent()) {
operation.expectedRevision = award.record().orElseThrow().revision(); attempt(operation); return;
operation.before=award.record().orElseThrow();operation.expectedRevision = operation.before.revision(); attempt(operation); return;
}
complete(operation);
if (award.record().isEmpty() || award.status() == CharacterStorage.CombatAwardStatus.UNAVAILABLE) return;
@@ -344,12 +350,9 @@ public final class CombatExperience implements Listener, AutoCloseable {
if (closed) return;
CharacterRecord saved = award.record().orElseThrow();
var active = sessions.activeCharacter(operation.character).orElse(null);
if (active != null && !sessions.acknowledgeProgress(active, saved.revision().subtract(BigInteger.ONE), saved)
&& !sessions.currentRecord(active).filter(saved::equals).isPresent()) {
report(operation.character, new IllegalStateException("Saved combat progress could not synchronize with active session"));
return;
}
Player player = active == null ? null : players.apply(active.player());
boolean synchronizedSession=active==null||sessions.acknowledgeProgress(active,saved.revision().subtract(BigInteger.ONE),saved)||sessions.currentRecord(active).filter(saved::equals).isPresent();
Player player=active==null?null:players.apply(active.player());String feedback=feedback(operation,award,player,synchronizedSession);var bridge=events;if(bridge!=null)bridge.resolved(operation.id,operation.character,operation.experience,operation.coins,operation.restricted,operation.rewardRules,operation.capture,operation.before,award,active,player,synchronizedSession?active==null?"not_applicable_offline":"acknowledged":"failed",feedback);
if(!synchronizedSession){report(operation.character,new IllegalStateException("Saved combat progress could not synchronize with active session"));return;}
if (player == null || !player.isOnline()) return;
if (award.status() == CharacterStorage.CombatAwardStatus.APPLIED) {
if (operation.experience.signum() > 0) player.sendMessage("Earned " + operation.experience + " fantasy XP.");
@@ -373,6 +376,8 @@ public final class CombatExperience implements Listener, AutoCloseable {
}
}
private static String feedback(PendingAward operation,CharacterStorage.CombatAward award,Player player,boolean synchronizedSession){if(!synchronizedSession)return "suppressed_native_sync_failed";if(player==null||!player.isOnline())return "not_delivered_offline";return switch(award.status()){case APPLIED->operation.experience.signum()>0&&operation.coins.signum()>0?"experience_and_coins":operation.experience.signum()>0?"experience":operation.coins.signum()>0?"coins":"none";case SPACING_BLOCKED->"spacing_refusal";case AT_CAP->operation.coins.signum()>0?"maximum_level_and_coins":"maximum_level";case UNCHANGED->"zero_reward";case DUPLICATE->"none_duplicate";case STALE,UNAVAILABLE->"none_unavailable";};}
public void configure(CombatRewardRules updated) {
if (closed) throw new IllegalStateException("Combat rewards are closed");
rewards = Objects.requireNonNull(updated);
@@ -9,19 +9,25 @@ import org.bukkit.entity.Player;
/** Registrar-authorized mutations with durable operation plans spanning coin and party files. */
public final class PartyManagement implements AutoCloseable {
public enum Status{APPLIED,INVALID_SESSION,TOO_FAR,INVALID_NAME,ALREADY_MEMBER,NAME_TAKEN,INSUFFICIENT,FAILED}
public enum Status{APPLIED,INVALID_SESSION,TOO_FAR,INVALID_NAME,ALREADY_MEMBER,NAME_TAKEN,INSUFFICIENT,FAILED,ERROR}
public record Outcome(Status status,Optional<CharacterParty> party){}
private record Context(EquipmentSessions.Active active,CharacterRecord record,PartyRegistrar registrar){}
private final CharacterPartyDirectory parties;private final PartyRegistrarDirectory registrars;private final CharacterStorage characters;private final EquipmentSessions sessions;private final PartyEconomySettings prices;private final PartyOperationStore operations;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;private volatile boolean closed;private volatile boolean recovered;private final java.util.concurrent.CompletableFuture<Void> recovery=new java.util.concurrent.CompletableFuture<>();
private final CharacterPartyDirectory parties;private final PartyRegistrarDirectory registrars;private final CharacterStorage characters;private final EquipmentSessions sessions;private final PartyEconomySettings prices;private final PartyOperationStore operations;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;private games.dmg.fantasystick.events.PartyEventBridge events;private volatile boolean closed;private volatile boolean recovered;private final java.util.concurrent.CompletableFuture<Void> recovery=new java.util.concurrent.CompletableFuture<>();
public PartyManagement(CharacterPartyDirectory parties,PartyRegistrarDirectory registrars,CharacterStorage characters,EquipmentSessions sessions,PartyEconomySettings prices,PartyOperationStore operations,Consumer<Runnable> nextTick,Consumer<Throwable> failures){this.parties=Objects.requireNonNull(parties);this.registrars=Objects.requireNonNull(registrars);this.characters=Objects.requireNonNull(characters);this.sessions=Objects.requireNonNull(sessions);this.prices=Objects.requireNonNull(prices);this.operations=Objects.requireNonNull(operations);this.nextTick=Objects.requireNonNull(nextTick);this.failures=Objects.requireNonNull(failures);parties.readiness().thenCompose(ignored->operations.load()).thenCompose(plans->java.util.concurrent.CompletableFuture.allOf(plans.stream().map(plan->execute(plan,null,null).toCompletableFuture()).toArray(java.util.concurrent.CompletableFuture[]::new))).whenComplete((ignored,failure)->{if(failure==null){recovered=true;recovery.complete(null);}else{report(failure);recovery.completeExceptionally(failure);}});}
public void register(Player player,UUID registrarId,String name,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var parsed=PartyNames.parse(name);if(parsed.isEmpty()){result.accept(new Outcome(Status.INVALID_NAME,Optional.empty()));return;}var captured=context.orElseThrow();if(parties.partyOf(captured.record().id()).isPresent()){result.accept(new Outcome(Status.ALREADY_MEMBER,Optional.empty()));return;}if(parties.partyByCanonicalName(parsed.orElseThrow().canonical()).isPresent()){result.accept(new Outcome(Status.NAME_TAKEN,Optional.empty()));return;}var plan=new PartyOperationStore.Plan(UUID.randomUUID(),PartyOperationStore.Kind.CREATE,captured.record().id(),captured.record().revision(),BigInteger.valueOf(prices.registrationPrice()),parsed.orElseThrow().display(),null,null,null);prepare(plan,captured,result);}
public void transfer(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();var party=parties.partyOf(captured.record().id()).orElse(null);if(party==null||!party.leader().equals(captured.record().id())||!party.members().contains(target)||target.equals(captured.record().id())){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}var plan=new PartyOperationStore.Plan(UUID.randomUUID(),PartyOperationStore.Kind.TRANSFER,captured.record().id(),captured.record().revision(),BigInteger.valueOf(prices.leaderChangePrice()),null,party.id(),target,party.revision());prepare(plan,captured,result);}
public void attachEvents(games.dmg.fantasystick.events.PartyEventBridge value){if(closed||events!=null)throw new IllegalStateException("Party events already attached or management closed");events=Objects.requireNonNull(value);}
public void register(Player player,UUID registrarId,String name,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();var before=parties.partyOf(captured.record().id()).orElse(null);var operation=UUID.randomUUID();var audit=audit(player,captured,"create",null,null,before,BigInteger.valueOf(prices.registrationPrice()),operation);var audited=wrap(audit,result);var parsed=PartyNames.parse(name);if(parsed.isEmpty()){audited.accept(new Outcome(Status.INVALID_NAME,Optional.empty()));return;}if(before!=null){audited.accept(new Outcome(Status.ALREADY_MEMBER,Optional.of(before)));return;}if(parties.partyByCanonicalName(parsed.orElseThrow().canonical()).isPresent()){audited.accept(new Outcome(Status.NAME_TAKEN,Optional.empty()));return;}var plan=new PartyOperationStore.Plan(operation,PartyOperationStore.Kind.CREATE,captured.record().id(),captured.record().revision(),BigInteger.valueOf(prices.registrationPrice()),parsed.orElseThrow().display(),null,null,null);prepare(plan,captured,audited);}
public void transfer(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();var party=parties.partyOf(captured.record().id()).orElse(null);var operation=UUID.randomUUID();var audit=audit(player,captured,"transfer",target,null,party,BigInteger.valueOf(prices.leaderChangePrice()),operation);var audited=wrap(audit,result);if(party==null||!party.leader().equals(captured.record().id())||!party.members().contains(target)||target.equals(captured.record().id())){audited.accept(new Outcome(Status.FAILED,Optional.empty()));return;}var plan=new PartyOperationStore.Plan(operation,PartyOperationStore.Kind.TRANSFER,captured.record().id(),captured.record().revision(),BigInteger.valueOf(prices.leaderChangePrice()),null,party.id(),target,party.revision());prepare(plan,captured,audited);}
private void prepare(PartyOperationStore.Plan plan,Context context,Consumer<Outcome> result){operations.prepare(plan).whenComplete((saved,failure)->{if(failure!=null){fail(failure,result);return;}execute(saved,context,result);});}
private java.util.concurrent.CompletionStage<Void> execute(PartyOperationStore.Plan plan,Context context,Consumer<Outcome> result){var done=new java.util.concurrent.CompletableFuture<Void>();parties.readiness().thenCompose(ignored->plan.kind()==PartyOperationStore.Kind.CREATE?parties.paidCreate(plan,characters):parties.paidTransfer(plan,characters)).whenComplete((paid,failure)->{if(failure!=null){if(result!=null)fail(failure,result);else report(failure);done.completeExceptionally(failure);return;}var spent=paid.spend();var changed=paid.result();if(spent!=null&&(spent.status()==CharacterStorage.CoinSpendStatus.INSUFFICIENT||spent.status()==CharacterStorage.CoinSpendStatus.UNAVAILABLE)){finish(plan,context,result,spent,new Outcome(Status.INSUFFICIENT,Optional.empty()),done);return;}if(spent==null){finish(plan,context,result,null,new Outcome(map(changed.status()),changed.party()),done);return;}if(spent.status()!=CharacterStorage.CoinSpendStatus.APPLIED&&spent.status()!=CharacterStorage.CoinSpendStatus.DUPLICATE){finish(plan,context,result,spent,new Outcome(Status.FAILED,Optional.empty()),done);return;}if(changed.status()!=CharacterPartyDirectory.Status.APPLIED&&changed.status()!=CharacterPartyDirectory.Status.DUPLICATE){finish(plan,context,result,spent,new Outcome(map(changed.status()),changed.party()),done);return;}finish(plan,context,result,spent,new Outcome(Status.APPLIED,changed.party()),done);});return done.minimalCompletionStage();}
private void finish(PartyOperationStore.Plan plan,Context context,Consumer<Outcome> result,CharacterStorage.CoinSpend spent,Outcome outcome,java.util.concurrent.CompletableFuture<Void> done){operations.complete(plan.id()).whenComplete((ignored,failure)->{if(failure!=null){if(result!=null)fail(failure,result);else report(failure);done.completeExceptionally(failure);return;}if(result!=null)publish(()->{if(context!=null&&spent!=null&&spent.record().isPresent())sessions.acknowledgeProgress(context.active(),context.record().revision(),spent.record().orElseThrow());result.accept(outcome);});done.complete(null);});}
private static Status map(CharacterPartyDirectory.Status status){return switch(status){case NAME_TAKEN->Status.NAME_TAKEN;case ALREADY_MEMBER->Status.ALREADY_MEMBER;default->Status.FAILED;};}
public void invite(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var active=sessions.activeCharacter(target).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(record==null||parties.partyOf(target).isPresent()){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}var party=parties.partyOf(context.orElseThrow().record().id()).orElse(null);if(party==null||!party.leader().equals(context.orElseThrow().record().id())){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}parties.invite(context.orElseThrow().record().id(),party.id(),target).whenComplete((value,failure)->complete(value,failure,result));}
public void accept(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){mutate(player,registrarId,c->parties.accept(c.record().id(),invitation),result);}public void decline(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){mutate(player,registrarId,c->parties.decline(c.record().id(),invitation),result);}public void cancel(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.cancel(c.record().id(),p.id(),invitation),result);}public void leave(Player player,UUID registrarId,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.leave(c.record().id(),p.id()),result);}public void kick(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.kick(c.record().id(),p.id(),target),result);}public void disband(Player player,UUID registrarId,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.disband(c.record().id(),p.id()),result);}
public void invite(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();var party=parties.partyOf(captured.record().id()).orElse(null);var audit=audit(player,captured,"invite",target,null,party,BigInteger.ZERO,null);var audited=wrap(audit,result);var active=sessions.activeCharacter(target).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(record==null||parties.partyOf(target).isPresent()||party==null||!party.leader().equals(captured.record().id())){audited.accept(new Outcome(Status.FAILED,Optional.empty()));return;}parties.invite(captured.record().id(),party.id(),target).whenComplete((value,failure)->complete(value,failure,audited));}
public void accept(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){var c=context(player,registrarId);var before=partyForInvitation(invitation);var audit=c.map(value->audit(player,value,"accept",value.record().id(),invitation,before,BigInteger.ZERO,null)).orElse(null);mutate(player,registrarId,value->parties.accept(value.record().id(),invitation),wrap(audit,result));}
public void decline(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){var c=context(player,registrarId);var before=partyForInvitation(invitation);var audit=c.map(value->audit(player,value,"decline",value.record().id(),invitation,before,BigInteger.ZERO,null)).orElse(null);mutate(player,registrarId,value->parties.decline(value.record().id(),invitation),wrap(audit,result));}
public void cancel(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){var c=context(player,registrarId);var before=c.flatMap(value->parties.partyOf(value.record().id())).orElse(null);var invited=before==null?null:before.invitations().stream().filter(value->value.id().equals(invitation)).map(CharacterParty.Invitation::character).findFirst().orElse(null);var audit=c.map(value->audit(player,value,"cancel",invited,invitation,before,BigInteger.ZERO,null)).orElse(null);mutateParty(player,registrarId,(value,p)->parties.cancel(value.record().id(),p.id(),invitation),wrap(audit,result));}
public void leave(Player player,UUID registrarId,Consumer<Outcome> result){var c=context(player,registrarId);var before=c.flatMap(value->parties.partyOf(value.record().id())).orElse(null);boolean leaderLeave=before!=null&&c.isPresent()&&before.leader().equals(c.orElseThrow().record().id());String kind=leaderLeave?"leader_leave":"leave";var audit=c.map(value->audit(player,value,kind,value.record().id(),null,before,BigInteger.ZERO,null,leaderLeave?"leader_leave":"requested")).orElse(null);mutateParty(player,registrarId,(value,p)->parties.leave(value.record().id(),p.id()),wrap(audit,result));}
public void kick(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){var c=context(player,registrarId);var before=c.flatMap(value->parties.partyOf(value.record().id())).orElse(null);var audit=c.map(value->audit(player,value,"kick",target,null,before,BigInteger.ZERO,null)).orElse(null);mutateParty(player,registrarId,(value,p)->parties.kick(value.record().id(),p.id(),target),wrap(audit,result));}
public void disband(Player player,UUID registrarId,Consumer<Outcome> result){var c=context(player,registrarId);var before=c.flatMap(value->parties.partyOf(value.record().id())).orElse(null);var audit=c.map(value->audit(player,value,"disband",value.record().id(),null,before,BigInteger.ZERO,null)).orElse(null);mutateParty(player,registrarId,(value,p)->parties.disband(value.record().id(),p.id()),wrap(audit,result));}
public Optional<CharacterParty> party(Player player,UUID registrar){return context(player,registrar).flatMap(value->parties.partyOf(value.record().id()));}public List<CharacterPartyDirectory.Offer> invitations(Player player,UUID registrar){return context(player,registrar).map(value->parties.invitationsFor(value.record().id())).orElse(List.of());}public List<CharacterPartyDirectory.Offer> outgoingInvitations(Player player,UUID registrar){var context=context(player,registrar);if(context.isEmpty())return List.of();var party=parties.partyOf(context.orElseThrow().record().id()).filter(value->value.leader().equals(context.orElseThrow().record().id())).orElse(null);return party==null?List.of():party.invitations().stream().map(value->new CharacterPartyDirectory.Offer(value.id(),party.id(),value.character())).toList();}
private void mutate(Player player,UUID id,java.util.function.Function<Context,java.util.concurrent.CompletableFuture<CharacterPartyDirectory.Result>> action,Consumer<Outcome> result){var context=context(player,id);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}try{action.apply(context.orElseThrow()).whenComplete((value,failure)->complete(value,failure,result));}catch(RuntimeException failure){fail(failure,result);}}
private void mutateParty(Player player,UUID id,java.util.function.BiFunction<Context,CharacterParty,java.util.concurrent.CompletableFuture<CharacterPartyDirectory.Result>> action,Consumer<Outcome> result){var context=context(player,id);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var party=parties.partyOf(context.orElseThrow().record().id()).orElse(null);if(party==null){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}try{action.apply(context.orElseThrow(),party).whenComplete((value,failure)->complete(value,failure,result));}catch(RuntimeException failure){fail(failure,result);}}
@@ -29,7 +35,12 @@ public final class PartyManagement implements AutoCloseable {
public int registrationPrice(){return prices.registrationPrice();}public int leaderChangePrice(){return prices.leaderChangePrice();}
public Optional<CharacterRecord> active(Player player,UUID registrar){return context(player,registrar).map(Context::record);}public Optional<UUID> activeSession(Player player,UUID registrar){return context(player,registrar).map(value->value.active().session());}public boolean eligibleInvitationTarget(UUID character){return parties.partyOf(character).isEmpty()&&sessions.activeCharacter(character).filter(sessions::current).flatMap(sessions::currentRecord).filter(value->!value.dead()).isPresent();}public Optional<CharacterRecord> activeCharacter(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);return active==null?Optional.empty():sessions.currentRecord(active).filter(value->!value.dead());}
private Optional<Context> context(Player player,UUID registrarId){if(!parties.ready()||!recovered)return Optional.empty();var registrar=registrars.find(registrarId).orElse(null);var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(registrar==null||record==null)return Optional.empty();var location=player.getLocation();if(location.getWorld()==null||!location.getWorld().getUID().equals(registrar.world()))return Optional.empty();double dx=location.getX()-registrar.x(),dy=location.getY()-registrar.y(),dz=location.getZ()-registrar.z();if(dx*dx+dy*dy+dz*dz>36)return Optional.empty();return Optional.of(new Context(active,record,registrar));}
private void fail(Throwable failure,Consumer<Outcome> result){report(failure);publish(()->result.accept(new Outcome(Status.FAILED,Optional.empty())));}private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}private void publish(Runnable action){if(closed)return;try{nextTick.accept(action);}catch(RuntimeException rejected){report(rejected);}}
private CharacterParty partyForInvitation(UUID invitation){return parties.all().stream().filter(party->party.invitations().stream().anyMatch(value->value.id().equals(invitation))).findFirst().orElse(null);}
private games.dmg.fantasystick.events.PartyEventBridge.Action audit(Player player,Context context,String kind,UUID target,UUID invitation,CharacterParty before,BigInteger charge,UUID operation){return audit(player,context,kind,target,invitation,before,charge,operation,"requested");}
private games.dmg.fantasystick.events.PartyEventBridge.Action audit(Player player,Context context,String kind,UUID target,UUID invitation,CharacterParty before,BigInteger charge,UUID operation,String reason){return events==null?null:events.begin(player,context.active(),context.record().name(),context.registrar(),kind,target,targetContext(player,context,target),invitation,before,charge,operation,reason);}
private games.dmg.fantasystick.events.EventInput.Context targetContext(Player actor,Context context,UUID target){if(target==null||events==null)return null;if(target.equals(context.record().id()))return events.context(actor,context.active(),context.record().name());var active=sessions.activeCharacter(target).orElse(null);if(active==null)return null;var player=sessions.currentPlayer(active).orElse(null);var record=sessions.currentRecord(active).orElse(null);return player==null||record==null?null:events.context(player,active,record.name());}
private Consumer<Outcome> wrap(games.dmg.fantasystick.events.PartyEventBridge.Action audit,Consumer<Outcome> target){return outcome->{if(audit!=null){if(outcome.status()==Status.ERROR)events.failed(audit,"mutation_failed_or_uncertain");else events.resolved(audit,outcome);}target.accept(outcome);};}
private void fail(Throwable failure,Consumer<Outcome> result){report(failure);publish(()->result.accept(new Outcome(Status.ERROR,Optional.empty())));}private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}private void publish(Runnable action){if(closed)return;try{nextTick.accept(action);}catch(RuntimeException rejected){report(rejected);}}
public java.util.concurrent.CompletionStage<Void> ready(){return recovery.minimalCompletionStage();}
public java.util.concurrent.CompletionStage<Void> drained(){return operations.drained();}
@Override public void close(){closed=true;operations.close();}
@@ -27,6 +27,7 @@ public final class PartyRegistrarController implements Listener, AutoCloseable {
public void install(){for(var world:plugin.getServer().getWorlds()){var chunks=world.getLoadedChunks();if(chunks!=null)for(var chunk:chunks)reconcile(chunk);}}
public boolean tagged(Entity entity){return "party_registrar".equals(entity.getPersistentDataContainer().get(typeKey,PersistentDataType.STRING));}
public void reconcile(Chunk chunk){if(closed)return;var desired=new HashMap<UUID,PartyRegistrar>();for(var value:directory.all())if(value.world().equals(chunk.getWorld().getUID())&&chunk(value.x())==chunk.getX()&&chunk(value.z())==chunk.getZ())desired.put(value.id(),value);var retained=new HashSet<UUID>();for(var entity:chunk.getEntities()){if(!tagged(entity))continue;UUID id=id(entity);var target=id==null?null:desired.get(id);if(!(entity instanceof Villager villager)||target==null||!retained.add(id)){entity.remove();continue;}configure(villager,target);var current=villager.getLocation();var expected=location(target);if(current.distanceSquared(expected)>0.000001||Math.abs(current.getYaw()-expected.getYaw())>0.001||Math.abs(current.getPitch()-expected.getPitch())>0.001){relocating.add(villager);boolean moved;try{moved=villager.teleport(expected);}finally{relocating.remove(villager);}if(!moved){villager.remove();spawn(target);}}}for(var value:desired.values())if(!retained.contains(value.id()))spawn(value);}
public java.util.Optional<UUID> loadedEntity(UUID id){for(var world:plugin.getServer().getWorlds()){var entities=world.getEntities();if(entities!=null)for(var entity:entities)if(tagged(entity)&&id.equals(id(entity)))return java.util.Optional.ofNullable(entity.getUniqueId());}return java.util.Optional.empty();}
public void removeLoaded(UUID id){for(var world:plugin.getServer().getWorlds()){var entities=world.getEntities();if(entities!=null)for(var entity:entities)if(tagged(entity)&&id.equals(id(entity)))entity.remove();}}
private void spawn(PartyRegistrar value){var world=plugin.getServer().getWorld(value.world());if(world==null)return;int x=chunk(value.x()),z=chunk(value.z());if(!world.isChunkLoaded(x,z))return;world.spawn(location(value),Villager.class,villager->configure(villager,value));}
private void configure(Villager villager,PartyRegistrar value){villager.getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,"party_registrar");villager.getPersistentDataContainer().set(idKey,PersistentDataType.STRING,value.id().toString());villager.setAI(false);villager.setGravity(false);villager.setSilent(true);villager.setInvulnerable(true);villager.setCollidable(false);villager.setCanPickupItems(false);villager.setLeashHolder(null);villager.setRemoveWhenFarAway(false);villager.setAdult();villager.setAgeLock(true);villager.customName(net.kyori.adventure.text.Component.text(value.name().isBlank()?"Party Registrar":value.name()));villager.setCustomNameVisible(true);}
@@ -72,10 +72,11 @@ public final class ChainLightningSettings implements AutoCloseable {
public String value(String key) { return effectiveValues.get(key); }
public ChainLightning.Settings settings() { return parse(effectiveValues); }
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) {
@@ -27,6 +27,7 @@ public final class CharacterStorage implements AutoCloseable {
private final java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules;
private final java.util.random.RandomGenerator coinRandom;
private final CharacterRecoveryStore recoveries;
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
private final ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-character-persistence"));
@@ -47,6 +48,7 @@ public final class CharacterStorage implements AutoCloseable {
}
public void attachUnavailableCharacter(java.util.function.Predicate<UUID> predicate){repository.attachUnavailable(predicate);}
public void attachCoinEvents(games.dmg.fantasystick.events.CoinEventBridge value){if(worker.isShutdown()||coinEvents!=null)throw new IllegalStateException("Coin events already attached or storage closed");coinEvents=Objects.requireNonNull(value);}
public CompletableFuture<Void> create(CharacterRecord character) {
return submit(() -> {
@@ -60,7 +62,8 @@ public final class CharacterStorage implements AutoCloseable {
* starting points and retains the returned handle through success, cancellation and disable.
* No inventory access occurs here; abandoning an observer is not abandoning the attempt.
*/
public CraftPreparation prepareCraft(List<CharacterRecord> records) {
public CraftPreparation prepareCraft(List<CharacterRecord> records) {return prepareCraft(records,"craft");}
public CraftPreparation prepareCraft(List<CharacterRecord> records,String source) {
var snapshot = List.copyOf(records);
var ids = new HashSet<UUID>();
if (snapshot.isEmpty()) throw new IllegalArgumentException("Craft batch must not be empty");
@@ -75,17 +78,19 @@ public final class CharacterStorage implements AutoCloseable {
throw new IllegalArgumentException("Craft batch requires distinct new characters");
}
}
return new CraftPreparation(snapshot);
return new CraftPreparation(snapshot,Objects.requireNonNull(source),coinRules.get());
}
public final class CraftPreparation {
private final List<CharacterRecord> requested;
private final CompletableFuture<CraftBatch> completion;
private final String source;
private final games.dmg.fantasystick.progression.CoinRules configuredRules;
private volatile boolean abandoned;
private CompletableFuture<CraftCleanup> cleanup;
private CraftPreparation(List<CharacterRecord> records) {
requested = records;
private CraftPreparation(List<CharacterRecord> records,String source,games.dmg.fantasystick.progression.CoinRules configuredRules) {
requested = records;this.source=source;this.configuredRules=Objects.requireNonNull(configuredRules);
completion = writeCraftBatch(records, this);
}
@@ -173,6 +178,7 @@ public final class CharacterStorage implements AutoCloseable {
if (resolved || owner.abandoned) return Optional.empty();
resolved = true;
if (worker.isShutdown()) return Optional.empty();
var bridge=coinEvents;if(bridge!=null)for(var record:records)bridge.initialized(record,owner.source,owner.configuredRules);
return Optional.of(records);
}
@@ -311,11 +317,11 @@ public final class CharacterStorage implements AutoCloseable {
reconcileRecovery(found.orElseThrow());
if (found.orElseThrow().coinsInitialized()) return found;
var old = found.orElseThrow();
var migrated = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
var configuredRules=coinRules.get();var migrated = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(),
old.cooldowns(), old.restrictedKills(), old.dead(), old.deathId(), old.diedAt(),
coinRules.get().starting(coinRandom), true, old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(migrated, old.revision());
configuredRules.starting(coinRandom), true, old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(migrated, old.revision());var bridge=coinEvents;if(bridge!=null)bridge.initialized(migrated,"legacy_migration",configuredRules);
return Optional.of(migrated);
}
@@ -801,7 +807,7 @@ public final class CharacterStorage implements AutoCloseable {
}
/** Finalize one claimed downed outcome with its stable death identity and original resolution time. */
public CompletableFuture<Optional<CharacterRecord>> recordFinalDeath(UUID characterId,BigInteger expectedRevision,UUID deathId,Instant diedAt,boolean loseCoins){Objects.requireNonNull(characterId);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(deathId);Objects.requireNonNull(diedAt);return submit(()->{var found=repository.load(characterId);if(found.isEmpty())return Optional.empty();var old=found.orElseThrow();if(old.dead())return old.deathId().equals(deathId)&&old.diedAt().equals(diedAt)?Optional.of(old):Optional.empty();if(!old.revision().equals(expectedRevision))return Optional.empty();var dead=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),java.math.BigDecimal.ZERO,old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),true,deathId,diedAt,loseCoins?BigInteger.ZERO:old.coins(),old.coinsInitialized(),old.rewardReceipts(),loseCoins?old.coins():BigInteger.ZERO,old.gear(),old.sheetGeneration());repository.replace(dead,old.revision());return Optional.of(dead);});}
public CompletableFuture<Optional<CharacterRecord>> recordFinalDeath(UUID characterId,BigInteger expectedRevision,UUID deathId,Instant diedAt,boolean loseCoins){Objects.requireNonNull(characterId);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(deathId);Objects.requireNonNull(diedAt);return submit(()->{var found=repository.load(characterId);if(found.isEmpty())return Optional.empty();var old=found.orElseThrow();if(old.dead())return old.deathId().equals(deathId)&&old.diedAt().equals(diedAt)?Optional.of(old):Optional.empty();if(!old.revision().equals(expectedRevision))return Optional.empty();var dead=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),java.math.BigDecimal.ZERO,old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),true,deathId,diedAt,loseCoins?BigInteger.ZERO:old.coins(),old.coinsInitialized(),old.rewardReceipts(),loseCoins?old.coins():BigInteger.ZERO,old.gear(),old.sheetGeneration());repository.replace(dead,old.revision());var bridge=coinEvents;if(bridge!=null&&loseCoins&&old.coins().signum()>0)bridge.lost(old,dead,deathId,diedAt);return Optional.of(dead);});}
private CompletableFuture<Optional<CharacterRecord>> death(UUID characterId, BigInteger expectedRevision, boolean loseCoins) {
Objects.requireNonNull(characterId);
@@ -71,10 +71,11 @@ public final class FrostNovaSettings implements AutoCloseable {
public String value(String key) { return effectiveValues.get(key); }
public FrostNova.Settings settings() { return parse(effectiveValues); }
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) {
@@ -70,10 +70,11 @@ public final class GuardianBondSettings implements AutoCloseable {
public String value(String key) { return effectiveValues.get(key); }
public GuardianBond.Settings settings() { return parse(effectiveValues); }
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) {
@@ -69,10 +69,11 @@ public final class MendingLightSettings implements AutoCloseable {
public String value(String key) { return effectiveValues.get(key); }
public MendingLight.Settings settings() { return parse(effectiveValues); }
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) {
@@ -72,10 +72,11 @@ public final class MeteorSettings implements AutoCloseable {
public String value(String key) { return effectiveValues.get(key); }
public Meteor.Settings settings() { return parse(effectiveValues); }
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) {
@@ -42,29 +42,81 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
private AccountPreference { Objects.requireNonNull(account); Objects.requireNonNull(preference); }
}
public record Update(UUID account, Preference preference, boolean enabled, long sequence) {
public record EventIntent(UUID actionId, Map<String, String> captured, long sequence) {
public EventIntent { Objects.requireNonNull(actionId); captured = Map.copyOf(captured); }
public EventIntent(UUID actionId, Map<String, String> captured) { this(actionId, captured, 0); }
}
public record Update(UUID account, Preference preference, boolean enabled, long sequence, EventIntent intent) {
public Update { Objects.requireNonNull(account); Objects.requireNonNull(preference); }
public Update(UUID account, Preference preference, boolean enabled, long sequence) {
this(account, preference, enabled, sequence, null);
}
}
private final Path directory;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-player-feedback-preferences"));
private final Map<AccountPreference, Boolean> effective = new java.util.HashMap<>();
private final Map<AccountPreference, Boolean> saved = new java.util.HashMap<>();
private final Map<AccountPreference, Long> published = new java.util.HashMap<>();
private long persistedSequence;
private final Map<UUID, EventIntent> pendingEvents = new LinkedHashMap<>();
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public PlayerFeedbackPreferences(Path directory) throws IOException {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
effective.putAll(loadEnabled());
var loaded = loadEnabled();
effective.putAll(loaded);
saved.putAll(loaded);
var root = readRoot();
var persisted = root.get("preference-sequence");
if (persisted != null) {
try { persistedSequence = Long.parseLong(String.valueOf(persisted)); }
catch (NumberFormatException invalid) { throw new IOException("Invalid preference sequence", invalid); }
if (persistedSequence < 0) throw new IOException("Invalid preference sequence");
}
var pending = stringMap(root.get("pending-preference-events"), "pending preference events");
for (var entry : pending.entrySet()) {
UUID id;
try { id = UUID.fromString(entry.getKey()); }
catch (IllegalArgumentException invalid) { throw new IOException("Invalid pending preference event identity", invalid); }
var row = stringMap(entry.getValue(), "pending preference event");
var captured = new LinkedHashMap<String, String>();
for (var field : row.entrySet()) {
if (!(field.getValue() instanceof String text)) throw new IOException("Invalid pending preference field");
captured.put(field.getKey(), text);
}
long sequence;
try { sequence = Long.parseLong(captured.remove("preference_sequence")); }
catch (RuntimeException invalid) { throw new IOException("Invalid pending preference sequence", invalid); }
if (sequence <= 0) throw new IOException("Invalid pending preference sequence");
pendingEvents.put(id, new EventIntent(id, captured, sequence));
persistedSequence = Math.max(persistedSequence, sequence);
}
}
/** Existing routine combat/effect feedback preference. */
public synchronized boolean enabled(UUID account) { return value(account, Preference.COMBAT_FEEDBACK); }
public synchronized boolean savedEnabled(UUID account) {
Objects.requireNonNull(account);
return saved.getOrDefault(new AccountPreference(account, Preference.COMBAT_FEEDBACK),
Preference.COMBAT_FEEDBACK.defaultValue);
}
public synchronized boolean playerAurasEnabled(UUID account) { return value(account, Preference.PLAYER_AURAS); }
public synchronized boolean allParticlesEnabled(UUID account) { return value(account, Preference.ALL_PARTICLES); }
public synchronized boolean savedPlayerAurasEnabled(UUID account) {
Objects.requireNonNull(account);
return saved.getOrDefault(new AccountPreference(account, Preference.PLAYER_AURAS),
Preference.PLAYER_AURAS.defaultValue);
}
public synchronized boolean savedAllParticlesEnabled(UUID account) {
Objects.requireNonNull(account);
return saved.getOrDefault(new AccountPreference(account, Preference.ALL_PARTICLES),
Preference.ALL_PARTICLES.defaultValue);
}
public synchronized boolean auraParticlesEnabled(UUID account) {
return value(account, Preference.ALL_PARTICLES) && value(account, Preference.PLAYER_AURAS);
}
@@ -78,16 +130,94 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
return set(account, Preference.COMBAT_FEEDBACK, enabled);
}
public CompletionStage<Update> set(UUID account, boolean enabled, EventIntent intent) {
return set(account, Preference.COMBAT_FEEDBACK, enabled, intent);
}
public CompletionStage<Update> setPlayerAuras(UUID account, boolean enabled) {
return set(account, Preference.PLAYER_AURAS, enabled);
}
public CompletionStage<Update> setPlayerAuras(UUID account, boolean enabled, EventIntent intent) {
return set(account, Preference.PLAYER_AURAS, enabled, intent);
}
/** Shared persisted precedence for the separately delivered all-particles command. */
public CompletionStage<Update> setAllParticles(UUID account, boolean enabled) {
return set(account, Preference.ALL_PARTICLES, enabled);
}
public CompletionStage<Update> setAllParticles(UUID account, boolean enabled, EventIntent intent) {
return set(account, Preference.ALL_PARTICLES, enabled, intent);
}
public synchronized java.util.List<EventIntent> pendingEvents() { return java.util.List.copyOf(pendingEvents.values()); }
/** Persist native publication proof independently of the event writer before a recovered success is claimed. */
public CompletionStage<Void> markPublished(UUID action) {
Objects.requireNonNull(action);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Player preferences closed"));
pending++;
}
try {
return CompletableFuture.runAsync(() -> {
try (var channel = FileChannel.open(directory.resolve(".player-preferences.lock"),
StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Player preferences busy");
var root = readRoot();
var all = stringMap(root.get("pending-preference-events"), "pending preference events");
var found = stringMap(all.get(action.toString()), "pending preference event");
if (found.isEmpty()) return; // Already accepted by the event writer.
found.put("published", "true");
all.put(action.toString(), found);
root.put("pending-preference-events", all);
writeRoot(root);
synchronized (this) {
var previous = pendingEvents.get(action);
if (previous != null) {
var snapshot = new LinkedHashMap<>(previous.captured());
snapshot.put("published", "true");
pendingEvents.put(action, new EventIntent(action, snapshot, previous.sequence()));
}
}
} catch (IOException | OverlappingFileLockException failure) {
throw new java.util.concurrent.CompletionException(failure);
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(rejected);
}
}
public CompletionStage<Void> acknowledgeEvent(UUID action) {
Objects.requireNonNull(action);
try {
return CompletableFuture.runAsync(() -> {
try (var channel = FileChannel.open(directory.resolve(".player-preferences.lock"),
StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Player preferences busy");
Map<String,Object> root = readRoot();
var pending = stringMap(root.get("pending-preference-events"), "pending preference events");
if (pending.remove(action.toString()) == null) return;
root.put("pending-preference-events", pending);
writeRoot(root);
synchronized (this) { pendingEvents.remove(action); }
} catch (IOException | OverlappingFileLockException failure) {
throw new java.util.concurrent.CompletionException(failure);
}
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException closedWorker) { return CompletableFuture.failedStage(closedWorker); }
}
private CompletionStage<Update> set(UUID account, Preference preference, boolean enabled) {
return set(account, preference, enabled, null);
}
private CompletionStage<Update> set(UUID account, Preference preference, boolean enabled, EventIntent intent) {
Objects.requireNonNull(account); Objects.requireNonNull(preference);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Player preference store is closed"));
@@ -96,8 +226,9 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
try {
return CompletableFuture.supplyAsync(() -> {
try {
save(account, preference, enabled);
return new Update(account, preference, enabled, ++persistedSequence);
long sequence = ++persistedSequence;
var accepted = save(account, preference, enabled, intent, sequence);
return new Update(account, preference, enabled, sequence, accepted);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(new IOException(
"Player preference save refused or failed; effective setting unchanged", failure));
@@ -142,7 +273,8 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
return result;
}
private void save(UUID account, Preference preference, boolean enabled) throws IOException {
private EventIntent save(UUID account, Preference preference, boolean enabled, EventIntent intent,
long sequence) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe data directory");
Path lockFile = directory.resolve(".player-preferences.lock");
@@ -155,9 +287,19 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
Map<String, Object> root = readRoot();
Map<String, Object> players = stringMap(root.get(PLAYERS), "player preferences");
Map<String, Object> values = stringMap(players.get(account.toString()), "player preference entry");
boolean before = values.get(preference.key) instanceof Boolean stored ? stored : preference.defaultValue;
values.put(preference.key, enabled);
players.put(account.toString(), values);
root.put(PLAYERS, players);
root.put("preference-sequence", Long.toString(sequence));
EventIntent accepted = intent != null && before != enabled ? new EventIntent(intent.actionId(), intent.captured(), sequence) : null;
if (accepted != null) {
var pending = stringMap(root.get("pending-preference-events"), "pending preference events");
var captured = new LinkedHashMap<String,Object>(accepted.captured());
captured.put("preference_sequence", Long.toString(sequence));
pending.put(accepted.actionId().toString(), captured);
root.put("pending-preference-events", pending);
}
byte[] bytes = yaml().dump(root).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Player preferences too large");
Path temporary = Files.createTempFile(directory, ".player-preferences-", ".tmp");
@@ -171,11 +313,32 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
StandardCopyOption.REPLACE_EXISTING);
DurableFiles.forceDirectory(directory);
} finally { Files.deleteIfExists(temporary); }
synchronized (this) {
saved.put(new AccountPreference(account, preference), enabled);
if (accepted != null) pendingEvents.put(accepted.actionId(), accepted);
}
return accepted;
} catch (OverlappingFileLockException busy) {
throw new IOException("Player preferences busy", busy);
}
}
private void writeRoot(Map<String,Object> root) throws IOException {
byte[] bytes = yaml().dump(root).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Player preferences too large");
Path temporary = Files.createTempFile(directory, ".player-preferences-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) output.write(buffer);
output.force(true);
}
Files.move(temporary, directory.resolve(FILE), StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
DurableFiles.forceDirectory(directory);
} finally { Files.deleteIfExists(temporary); }
}
private Map<String, Object> readRoot() throws IOException {
Path file = directory.resolve(FILE);
if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return new LinkedHashMap<>();
@@ -67,10 +67,11 @@ public final class PurifySettings implements AutoCloseable {
public String value(String key) { return effectiveValues.get(key); }
public Purify.Settings settings() { return parse(effectiveValues); }
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) {
@@ -71,10 +71,11 @@ public final class RallyingCrySettings implements AutoCloseable {
public String value(String key) { return effectiveValues.get(key); }
public RallyingCry.Settings settings() { return parse(effectiveValues); }
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) {
@@ -73,10 +73,11 @@ public final class SanctuarySettings implements AutoCloseable {
public String value(String key) { return effectiveValues.get(key); }
public Sanctuary.Settings settings() { return parse(effectiveValues); }
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) {
@@ -75,10 +75,11 @@ public final class WarBannerSettings implements AutoCloseable {
public String value(String key) { return effectiveValues.get(key); }
public WarBanner.Settings settings() { return parse(effectiveValues); }
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) {
@@ -41,7 +41,7 @@ public final class AdminCharacterIssuer implements AutoCloseable {
if (pending.putIfAbsent(attempt.playerId, attempt) != null) { response.accept(Outcome.BUSY); return; }
try {
attempt.record = allocate.get();
attempt.preparation = storage.prepareCraft(List.of(attempt.record));
attempt.preparation = storage.prepareCraft(List.of(attempt.record),"admin_issue");
attempt.preparation.ready().whenComplete((batch, failure) -> schedule(attempt, batch, failure));
} catch (RuntimeException failure) {
pending.remove(attempt.playerId, attempt);
@@ -33,7 +33,7 @@ public final class DownedDirectory implements AutoCloseable{
private Map<UUID,DownedEpisode> load()throws IOException{if(Files.notExists(file))return Map.of();try{if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS)||Files.isSymbolicLink(file)||Files.size(file)>1_048_576)throw new IllegalArgumentException();Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));if(!(decoded instanceof Map<?,?> root)||!"1".equals(String.valueOf(root.get("schema-version"))))throw new IllegalArgumentException();var result=new LinkedHashMap<UUID,DownedEpisode>();for(Object raw:list(root.get("episodes"))){var row=map(raw);var value=decode(row);if(result.put(value.character(),value)!=null)throw new IllegalArgumentException();}return Map.copyOf(result);}catch(RuntimeException invalid){throw new IOException("Invalid downed state",invalid);}}
private void save(Map<UUID,DownedEpisode> state)throws IOException{Files.createDirectories(file.getParent());var root=new LinkedHashMap<String,Object>();root.put("schema-version","1");root.put("episodes",state.values().stream().sorted(Comparator.comparing(DownedEpisode::character)).map(this::encode).toList());Path temporary=Files.createTempFile(file.getParent(),".downed-",".tmp");try{byte[] bytes=yaml().dump(root).getBytes(StandardCharsets.UTF_8);try(var out=FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())out.write(buffer);out.force(true);}Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);try(var directory=FileChannel.open(file.getParent(),StandardOpenOption.READ)){directory.force(true);}snapshot=Map.copyOf(state);}finally{Files.deleteIfExists(temporary);}}
private Map<String,Object> encode(DownedEpisode value){var row=new LinkedHashMap<String,Object>();row.put("id",value.id().toString());row.put("character",value.character().toString());row.put("player",value.player().toString());row.put("session",value.session().toString());row.put("world",value.world().toString());row.put("x",BigDecimal.valueOf(value.x()).toPlainString());row.put("y",BigDecimal.valueOf(value.y()).toPlainString());row.put("z",BigDecimal.valueOf(value.z()).toPlainString());row.put("yaw",Float.toString(value.yaw()));row.put("pitch",Float.toString(value.pitch()));row.put("entry-world",value.entryWorld().toString());row.put("entry-x",BigDecimal.valueOf(value.entryX()).toPlainString());row.put("entry-y",BigDecimal.valueOf(value.entryY()).toPlainString());row.put("entry-z",BigDecimal.valueOf(value.entryZ()).toPlainString());row.put("entry-yaw",Float.toString(value.entryYaw()));row.put("entry-pitch",Float.toString(value.entryPitch()));row.put("downed-at",value.downedAt().toString());row.put("expires-at",value.expiresAt().toString());row.put("attacker",value.attribution().attacker()==null?null:value.attribution().attacker().toString());row.put("cause",value.attribution().cause());row.put("player-defeat",value.attribution().playerDefeat());row.put("contributors",value.attribution().contributors().stream().map(UUID::toString).collect(java.util.stream.Collectors.toCollection(ArrayList::new)));row.put("victim-level",value.attribution().victimLevel());row.put("victim-coins",value.attribution().victimCoins().toString());row.put("character-name",value.characterName());row.put("player-name",value.playerName());row.put("character-revision",value.characterRevision()==null?null:value.characterRevision().toString());row.put("phase",value.phase().name());row.put("outcome",value.outcome()==null?null:value.outcome().name());row.put("outcome-operation",value.outcomeOperation()==null?null:value.outcomeOperation().toString());row.put("resolved-at",value.resolvedAt()==null?null:value.resolvedAt().toString());row.put("protection-until",value.protectionUntil()==null?null:value.protectionUntil().toString());var decision=value.decision();row.put("decision-source",decision==null?null:decision.source());row.put("decision-actor-player",decision==null||decision.actorPlayer()==null?null:decision.actorPlayer().toString());row.put("decision-actor-player-name",decision==null?null:decision.actorPlayerName());row.put("decision-actor-character",decision==null||decision.actorCharacter()==null?null:decision.actorCharacter().toString());row.put("decision-actor-character-name",decision==null?null:decision.actorCharacterName());row.put("decision-actor-session",decision==null||decision.actorSession()==null?null:decision.actorSession().toString());row.put("decision-action",decision==null?null:decision.actionId().toString());row.put("decision-cast-at",decision==null||decision.castAt()==null?null:decision.castAt().toString());row.put("decision-cooldown-until",decision==null||decision.cooldownUntil()==null?null:decision.cooldownUntil().toString());row.put("decision-final-range",decision==null||decision.finalRange()==null?null:Double.toString(decision.finalRange()));row.put("decision-line-of-sight",decision==null?null:decision.lineOfSight());var phoenix=decision==null?null:decision.phoenix();row.put("decision-phoenix-quantity-before",phoenix==null?null:phoenix.quantityBefore().toString());row.put("decision-phoenix-quantity-after",phoenix==null?null:phoenix.quantityAfter().toString());row.put("decision-phoenix-health-before",phoenix==null?null:phoenix.healthBefore().toPlainString());row.put("decision-phoenix-health-after",phoenix==null?null:phoenix.healthAfter().toPlainString());row.put("decision-phoenix-maximum",phoenix==null?null:phoenix.maximum().toPlainString());row.put("decision-phoenix-entry-point",phoenix==null?null:phoenix.entryPoint());row.put("final-rewards",value.finalRewards().stream().map(reward->{var saved=new LinkedHashMap<String,Object>();saved.put("character",reward.character().toString());saved.put("experience",reward.experience().toString());saved.put("coins",reward.coins().toString());return saved;}).collect(java.util.stream.Collectors.toCollection(ArrayList::new)));row.put("published",value.published());return row;}
private Map<String,Object> encode(DownedEpisode value){var row=new LinkedHashMap<String,Object>();row.put("id",value.id().toString());row.put("character",value.character().toString());row.put("player",value.player().toString());row.put("session",value.session().toString());row.put("world",value.world().toString());row.put("x",BigDecimal.valueOf(value.x()).toPlainString());row.put("y",BigDecimal.valueOf(value.y()).toPlainString());row.put("z",BigDecimal.valueOf(value.z()).toPlainString());row.put("yaw",Float.toString(value.yaw()));row.put("pitch",Float.toString(value.pitch()));row.put("entry-world",value.entryWorld().toString());row.put("entry-x",BigDecimal.valueOf(value.entryX()).toPlainString());row.put("entry-y",BigDecimal.valueOf(value.entryY()).toPlainString());row.put("entry-z",BigDecimal.valueOf(value.entryZ()).toPlainString());row.put("entry-yaw",Float.toString(value.entryYaw()));row.put("entry-pitch",Float.toString(value.entryPitch()));row.put("downed-at",value.downedAt().toString());row.put("expires-at",value.expiresAt().toString());row.put("attacker",value.attribution().attacker()==null?null:value.attribution().attacker().toString());row.put("cause",value.attribution().cause());row.put("player-defeat",value.attribution().playerDefeat());row.put("contributors",value.attribution().contributors().stream().map(UUID::toString).collect(java.util.stream.Collectors.toCollection(ArrayList::new)));row.put("victim-level",value.attribution().victimLevel());row.put("victim-coins",value.attribution().victimCoins().toString());row.put("character-name",value.characterName());row.put("player-name",value.playerName());row.put("character-revision",value.characterRevision()==null?null:value.characterRevision().toString());row.put("phase",value.phase().name());row.put("outcome",value.outcome()==null?null:value.outcome().name());row.put("outcome-operation",value.outcomeOperation()==null?null:value.outcomeOperation().toString());row.put("resolved-at",value.resolvedAt()==null?null:value.resolvedAt().toString());row.put("protection-until",value.protectionUntil()==null?null:value.protectionUntil().toString());var decision=value.decision();row.put("decision-source",decision==null?null:decision.source());row.put("decision-actor-player",decision==null||decision.actorPlayer()==null?null:decision.actorPlayer().toString());row.put("decision-actor-player-name",decision==null?null:decision.actorPlayerName());row.put("decision-actor-character",decision==null||decision.actorCharacter()==null?null:decision.actorCharacter().toString());row.put("decision-actor-character-name",decision==null?null:decision.actorCharacterName());row.put("decision-actor-session",decision==null||decision.actorSession()==null?null:decision.actorSession().toString());row.put("decision-action",decision==null?null:decision.actionId().toString());row.put("decision-cast-at",decision==null||decision.castAt()==null?null:decision.castAt().toString());row.put("decision-cooldown-until",decision==null||decision.cooldownUntil()==null?null:decision.cooldownUntil().toString());row.put("decision-final-range",decision==null||decision.finalRange()==null?null:Double.toString(decision.finalRange()));row.put("decision-line-of-sight",decision==null?null:decision.lineOfSight());var phoenix=decision==null?null:decision.phoenix();row.put("decision-phoenix-quantity-before",phoenix==null?null:phoenix.quantityBefore().toString());row.put("decision-phoenix-quantity-after",phoenix==null?null:phoenix.quantityAfter().toString());row.put("decision-phoenix-health-before",phoenix==null?null:phoenix.healthBefore().toPlainString());row.put("decision-phoenix-health-after",phoenix==null?null:phoenix.healthAfter().toPlainString());row.put("decision-phoenix-maximum",phoenix==null?null:phoenix.maximum().toPlainString());row.put("decision-phoenix-entry-point",phoenix==null?null:phoenix.entryPoint());row.put("final-rewards",value.finalRewards().stream().map(reward->{var saved=new LinkedHashMap<String,Object>();saved.put("character",reward.character().toString());saved.put("experience",reward.experience().toString());saved.put("coins",reward.coins().toString());saved.put("unit-id",reward.unitId());saved.put("unit-count",reward.unitCount());return saved;}).collect(java.util.stream.Collectors.toCollection(ArrayList::new)));row.put("published",value.published());return row;}
private DownedEpisode decode(Map<?,?> row){
String attacker=optional(row,"attacker"),outcome=optional(row,"outcome"),operation=optional(row,"outcome-operation"),resolved=optional(row,"resolved-at"),protection=optional(row,"protection-until"),revision=optional(row,"character-revision"),characterName=optional(row,"character-name"),playerName=optional(row,"player-name"),decisionSource=optional(row,"decision-source"),decisionPlayer=optional(row,"decision-actor-player"),decisionPlayerName=optional(row,"decision-actor-player-name"),decisionCharacter=optional(row,"decision-actor-character"),decisionCharacterName=optional(row,"decision-actor-character-name"),decisionSession=optional(row,"decision-actor-session"),decisionAction=optional(row,"decision-action"),decisionCastAt=optional(row,"decision-cast-at"),decisionCooldown=optional(row,"decision-cooldown-until"),decisionRange=optional(row,"decision-final-range");
UUID world=uuid(row,"world"),entryWorld=row.containsKey("entry-world")?uuid(row,"entry-world"):world;
@@ -41,7 +41,7 @@ public final class DownedDirectory implements AutoCloseable{
float yaw=Float.parseFloat(text(row,"yaw")),pitch=Float.parseFloat(text(row,"pitch"));
var phoenixQuantity=optional(row,"decision-phoenix-quantity-before");var phoenix=phoenixQuantity==null?null:new DownedEpisode.Phoenix(new java.math.BigInteger(phoenixQuantity),new java.math.BigInteger(text(row,"decision-phoenix-quantity-after")),new java.math.BigDecimal(text(row,"decision-phoenix-health-before")),new java.math.BigDecimal(text(row,"decision-phoenix-health-after")),new java.math.BigDecimal(text(row,"decision-phoenix-maximum")),text(row,"decision-phoenix-entry-point"));
var decision=decisionSource==null?null:new DownedEpisode.Decision(decisionSource,decisionPlayer==null?null:UUID.fromString(decisionPlayer),decisionPlayerName,decisionCharacter==null?null:UUID.fromString(decisionCharacter),decisionCharacterName,decisionSession==null?null:UUID.fromString(decisionSession),UUID.fromString(decisionAction),decisionCastAt==null?null:Instant.parse(decisionCastAt),decisionCooldown==null?null:Instant.parse(decisionCooldown),decisionRange==null?null:Double.valueOf(decisionRange),row.get("decision-line-of-sight")==null?null:Boolean.valueOf(String.valueOf(row.get("decision-line-of-sight"))),phoenix);
return new DownedEpisode(uuid(row,"id"),uuid(row,"character"),uuid(row,"player"),uuid(row,"session"),world,x,y,z,yaw,pitch,entryWorld,row.containsKey("entry-x")?Double.parseDouble(text(row,"entry-x")):x,row.containsKey("entry-y")?Double.parseDouble(text(row,"entry-y")):y,row.containsKey("entry-z")?Double.parseDouble(text(row,"entry-z")):z,row.containsKey("entry-yaw")?Float.parseFloat(text(row,"entry-yaw")):yaw,row.containsKey("entry-pitch")?Float.parseFloat(text(row,"entry-pitch")):pitch,Instant.parse(text(row,"downed-at")),Instant.parse(text(row,"expires-at")),new DownedEpisode.Attribution(attacker==null?null:UUID.fromString(attacker),text(row,"cause"),Boolean.parseBoolean(String.valueOf(row.containsKey("player-defeat")?row.get("player-defeat"):false)),row.containsKey("contributors")?list(row.get("contributors")).stream().map(String::valueOf).map(UUID::fromString).toList():List.of(),row.containsKey("victim-level")?Integer.parseInt(String.valueOf(row.get("victim-level"))):0,row.containsKey("victim-coins")?new java.math.BigInteger(String.valueOf(row.get("victim-coins"))):java.math.BigInteger.ZERO),characterName,playerName,revision==null?null:new java.math.BigInteger(revision),DownedEpisode.Phase.valueOf(text(row,"phase")),outcome==null?null:DownedEpisode.Outcome.valueOf(outcome),operation==null?null:UUID.fromString(operation),resolved==null?null:Instant.parse(resolved),protection==null?null:Instant.parse(protection),decision,list(row.get("final-rewards")).stream().map(DownedDirectory::map).map(reward->new DownedEpisode.Reward(uuid(reward,"character"),new java.math.BigInteger(text(reward,"experience")),new java.math.BigInteger(text(reward,"coins")))).toList(),Boolean.parseBoolean(String.valueOf(row.containsKey("published")?row.get("published"):false)));
return new DownedEpisode(uuid(row,"id"),uuid(row,"character"),uuid(row,"player"),uuid(row,"session"),world,x,y,z,yaw,pitch,entryWorld,row.containsKey("entry-x")?Double.parseDouble(text(row,"entry-x")):x,row.containsKey("entry-y")?Double.parseDouble(text(row,"entry-y")):y,row.containsKey("entry-z")?Double.parseDouble(text(row,"entry-z")):z,row.containsKey("entry-yaw")?Float.parseFloat(text(row,"entry-yaw")):yaw,row.containsKey("entry-pitch")?Float.parseFloat(text(row,"entry-pitch")):pitch,Instant.parse(text(row,"downed-at")),Instant.parse(text(row,"expires-at")),new DownedEpisode.Attribution(attacker==null?null:UUID.fromString(attacker),text(row,"cause"),Boolean.parseBoolean(String.valueOf(row.containsKey("player-defeat")?row.get("player-defeat"):false)),row.containsKey("contributors")?list(row.get("contributors")).stream().map(String::valueOf).map(UUID::fromString).toList():List.of(),row.containsKey("victim-level")?Integer.parseInt(String.valueOf(row.get("victim-level"))):0,row.containsKey("victim-coins")?new java.math.BigInteger(String.valueOf(row.get("victim-coins"))):java.math.BigInteger.ZERO),characterName,playerName,revision==null?null:new java.math.BigInteger(revision),DownedEpisode.Phase.valueOf(text(row,"phase")),outcome==null?null:DownedEpisode.Outcome.valueOf(outcome),operation==null?null:UUID.fromString(operation),resolved==null?null:Instant.parse(resolved),protection==null?null:Instant.parse(protection),decision,list(row.get("final-rewards")).stream().map(DownedDirectory::map).map(reward->new DownedEpisode.Reward(uuid(reward,"character"),new java.math.BigInteger(text(reward,"experience")),new java.math.BigInteger(text(reward,"coins")),reward.containsKey("unit-id")?text(reward,"unit-id"):"legacy:"+text(reward,"character"),reward.containsKey("unit-count")?Integer.parseInt(String.valueOf(reward.get("unit-count"))):1)).toList(),Boolean.parseBoolean(String.valueOf(row.containsKey("published")?row.get("published"):false)));
}
private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(0);options.setCodePointLimit(1_048_576);return new Yaml(new SafeConstructor(options));}private static List<?> list(Object value){return value==null?List.of():(List<?>)value;}private static Map<?,?> map(Object value){return(Map<?,?>)value;}private static String text(Map<?,?> map,String key){Object value=map.get(key);if(!(value instanceof String text)||text.isBlank())throw new IllegalArgumentException(key);return text;}private static String optional(Map<?,?> map,String key){Object value=map.get(key);return value==null?null:text(map,key);}private static UUID uuid(Map<?,?> map,String key){return UUID.fromString(text(map,key));}
private <T>CompletableFuture<T> submit(Callable<T> work){var result=new CompletableFuture<T>();synchronized(this){if(closed){result.completeExceptionally(new IOException("Downed directory closed"));return result;}pending++;}worker.execute(()->{try{result.complete(work.call());}catch(Throwable failure){result.completeExceptionally(failure);}finally{finished();}});return result;}private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
@@ -19,12 +19,14 @@ public record DownedEpisode(UUID id, UUID character, UUID player, UUID session,
public enum Phase { PENDING, RESOLVED }
public enum Outcome { REVIVED, FINAL_DEATH }
public record Reward(UUID character, BigInteger experience, BigInteger coins) {
public record Reward(UUID character, BigInteger experience, BigInteger coins,String unitId,int unitCount) {
public Reward(UUID character,BigInteger experience,BigInteger coins){this(character,experience,coins,"legacy:"+character,1);}
public Reward {
Objects.requireNonNull(character);
Objects.requireNonNull(experience);
Objects.requireNonNull(coins);
if (experience.signum() < 0 || coins.signum() < 0) {
Objects.requireNonNull(unitId);
if (experience.signum() < 0 || coins.signum() < 0 || unitId.isBlank() || unitCount < 1) {
throw new IllegalArgumentException("Invalid downed reward");
}
}
@@ -27,6 +27,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private volatile boolean closed;
private games.dmg.fantasystick.persistence.AdventureSettings settings;
private games.dmg.fantasystick.persistence.FireballSettings fireballSettings;
private games.dmg.fantasystick.events.AbilityConfigEventBridge abilityConfigEvents;
private games.dmg.fantasystick.ability.Fireball fireball;
private games.dmg.fantasystick.persistence.FrostShardSettings frostSettings;
private games.dmg.fantasystick.ability.FrostShard frostShard;
@@ -73,6 +74,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.persistence.PartyEconomySettings partyEconomySettings;
private games.dmg.fantasystick.persistence.GearSettings gearSettings;
private games.dmg.fantasystick.persistence.CoinSettings coinSettings;
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
private games.dmg.fantasystick.persistence.HardMobXpSettings hardMobXpSettings;
private games.dmg.fantasystick.persistence.AdventureQuestSettings adventureQuestSettings;
private games.dmg.fantasystick.adventure.AdventureQuestCommands adventureQuestCommands;
@@ -84,6 +86,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private OutOfCombatRecovery outOfCombatRecovery;
private games.dmg.fantasystick.persistence.QuickerRegenerationSettings quickerRegenerationSettings;
private games.dmg.fantasystick.passive.QuickerRegenerationI quickerRegeneration;
private games.dmg.fantasystick.events.PassiveEventBridge passiveEvents;
private games.dmg.fantasystick.persistence.QuickerRegenerationIISettings quickerRegenerationIISettings;
private games.dmg.fantasystick.passive.QuickerRegenerationII quickerRegenerationII;
private games.dmg.fantasystick.persistence.HealthStealSettings healthStealSettings;
@@ -91,6 +94,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
private games.dmg.fantasystick.party.PartyRegistrarDirectory partyRegistrars;
private games.dmg.fantasystick.party.PartyRegistrarController partyRegistrarController;
private games.dmg.fantasystick.events.PartyEventBridge partyEvents;
private games.dmg.fantasystick.training.TrainingDummyDirectory trainingDummies;
private games.dmg.fantasystick.training.TrainingDummyController trainingDummyController;
private games.dmg.fantasystick.shop.FantasyArmorerDirectory armorers;
@@ -334,6 +338,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
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);}
public void attachCoinEvents(games.dmg.fantasystick.events.CoinEventBridge events){if(closed||coinEvents!=null)throw new IllegalStateException("Coin events already attached or command closed");coinEvents=Objects.requireNonNull(events);}
public void attachAbilityConfigEvents(games.dmg.fantasystick.events.AbilityConfigEventBridge events){if(closed||abilityConfigEvents!=null)throw new IllegalStateException("Ability config events already attached or command closed");abilityConfigEvents=Objects.requireNonNull(events);}
public void attachHardMobXpSettings(games.dmg.fantasystick.persistence.HardMobXpSettings settings,
games.dmg.fantasystick.party.CombatExperience experience) {
if (closed || hardMobXpSettings != null) throw new IllegalStateException("Hard-mob XP settings already attached or command closed");
@@ -353,6 +359,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
outOfCombatRecovery = Objects.requireNonNull(recovery);
}
public void attachPassiveEvents(games.dmg.fantasystick.events.PassiveEventBridge events){if(closed||passiveEvents!=null)throw new IllegalStateException("Passive events already attached or command closed");passiveEvents=Objects.requireNonNull(events);}
public void attachQuickerRegeneration(games.dmg.fantasystick.persistence.QuickerRegenerationSettings settings,
games.dmg.fantasystick.passive.QuickerRegenerationI passive,
games.dmg.fantasystick.passive.PassiveRegistry registry) {
@@ -404,6 +412,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
characterRecovery = Objects.requireNonNull(recovery);
}
public void attachPartyEvents(games.dmg.fantasystick.events.PartyEventBridge events){if(closed||partyEvents!=null)throw new IllegalStateException("Party events already attached or command closed");partyEvents=Objects.requireNonNull(events);}
public void attachPartyRegistrars(games.dmg.fantasystick.party.PartyRegistrarDirectory directory,
games.dmg.fantasystick.party.PartyRegistrarController controller) {
if (closed || partyRegistrars != null) throw new IllegalStateException("Party Registrars already attached or command closed");
@@ -882,8 +892,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
});
}
private void placeRegistrarResponse(org.bukkit.entity.Player player,org.bukkit.Location location,games.dmg.fantasystick.party.PartyRegistrar saved,Throwable failure){if(failure!=null)report(player.getUniqueId(),"party-registrar-place",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure!=null){if(player.isOnline())player.sendMessage("Party Registrar placement failed; no registration or entity changed.");return;}try{int chunkX=location.getBlockX()>>4,chunkZ=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(chunkX,chunkZ))partyRegistrarController.reconcile(location.getWorld().getChunkAt(chunkX,chunkZ));if(player.isOnline())player.sendMessage("Placed Party Registrar "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"party-registrar-entity",entityFailure);partyRegistrars.remove(saved.id()).whenComplete((ignored,rollbackFailure)->{if(rollbackFailure!=null)report(player.getUniqueId(),"party-registrar-rollback",rollbackFailure);if(closed)return;nextTick.accept(()->{partyRegistrarController.removeLoaded(saved.id());if(player.isOnline())player.sendMessage("Party Registrar placement failed; no registration or entity changed.");});});}});}catch(RuntimeException rejected){report(player.getUniqueId(),"party-registrar-response",rejected);}}
private void removeRegistrarResponse(CommandSender sender,UUID id,java.util.Optional<games.dmg.fantasystick.party.PartyRegistrar> removed,Throwable failure){if(failure!=null)report(null,"party-registrar-remove",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure!=null)sender.sendMessage("Party Registrar removal failed; registration and entity are unchanged.");else if(removed.isEmpty())sender.sendMessage("Party Registrar not found.");else try{partyRegistrarController.removeLoaded(id);sender.sendMessage("Removed Party Registrar "+id+".");}catch(RuntimeException entityFailure){report(null,"party-registrar-entity",entityFailure);partyRegistrars.restore(removed.orElseThrow()).whenComplete((restored,rollbackFailure)->{if(rollbackFailure!=null)report(null,"party-registrar-rollback",rollbackFailure);if(closed)return;nextTick.accept(()->{if(rollbackFailure==null){var world=server.getWorld(restored.world());int chunkX=((int)Math.floor(restored.x()))>>4,chunkZ=((int)Math.floor(restored.z()))>>4;if(world!=null&&world.isChunkLoaded(chunkX,chunkZ))partyRegistrarController.reconcile(world.getChunkAt(chunkX,chunkZ));}sender.sendMessage("Party Registrar removal failed; registration and entity are unchanged.");});});}});}catch(RuntimeException rejected){report(null,"party-registrar-response",rejected);}}
private void placeRegistrarResponse(org.bukkit.entity.Player player,org.bukkit.Location location,games.dmg.fantasystick.party.PartyRegistrar saved,Throwable failure){if(failure!=null)report(player.getUniqueId(),"party-registrar-place",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure!=null){if(player.isOnline())player.sendMessage("Party Registrar placement failed; no registration or entity changed.");return;}try{int chunkX=location.getBlockX()>>4,chunkZ=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(chunkX,chunkZ))partyRegistrarController.reconcile(location.getWorld().getChunkAt(chunkX,chunkZ));if(partyEvents!=null)partyEvents.registrarPlaced("player",player.getUniqueId(),player.getName(),saved,partyRegistrarController.loadedEntity(saved.id()).orElse(null),partyRegistrarController.loadedEntity(saved.id()).isPresent());if(player.isOnline())player.sendMessage("Placed Party Registrar "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"party-registrar-entity",entityFailure);partyRegistrars.remove(saved.id()).whenComplete((ignored,rollbackFailure)->{if(rollbackFailure!=null)report(player.getUniqueId(),"party-registrar-rollback",rollbackFailure);if(closed)return;nextTick.accept(()->{partyRegistrarController.removeLoaded(saved.id());if(player.isOnline())player.sendMessage("Party Registrar placement failed; no registration or entity changed.");});});}});}catch(RuntimeException rejected){report(player.getUniqueId(),"party-registrar-response",rejected);}}
private void removeRegistrarResponse(CommandSender sender,UUID id,java.util.Optional<games.dmg.fantasystick.party.PartyRegistrar> removed,Throwable failure){if(failure!=null)report(null,"party-registrar-remove",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure!=null)sender.sendMessage("Party Registrar removal failed; registration and entity are unchanged.");else if(removed.isEmpty())sender.sendMessage("Party Registrar not found.");else try{var loaded=partyRegistrarController.loadedEntity(id);partyRegistrarController.removeLoaded(id);if(partyEvents!=null){String actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;partyEvents.registrarRemoved(actorKind,actorPlayer,actorName,removed.orElseThrow(),loaded.orElse(null),false);}sender.sendMessage("Removed Party Registrar "+id+".");}catch(RuntimeException entityFailure){report(null,"party-registrar-entity",entityFailure);partyRegistrars.restore(removed.orElseThrow()).whenComplete((restored,rollbackFailure)->{if(rollbackFailure!=null)report(null,"party-registrar-rollback",rollbackFailure);if(closed)return;nextTick.accept(()->{if(rollbackFailure==null){var world=server.getWorld(restored.world());int chunkX=((int)Math.floor(restored.x()))>>4,chunkZ=((int)Math.floor(restored.z()))>>4;if(world!=null&&world.isChunkLoaded(chunkX,chunkZ))partyRegistrarController.reconcile(world.getChunkAt(chunkX,chunkZ));}sender.sendMessage("Party Registrar removal failed; registration and entity are unchanged.");});});}});}catch(RuntimeException rejected){report(null,"party-registrar-response",rejected);}}
private void partyRegistrar(CommandSender sender,String[] args){
if(partyRegistrars==null||partyRegistrarController==null||args.length<2){sender.sendMessage("Usage: /fstickadmin party-registrar <place [name...]|remove <registrar-uuid>|list>");return;}
if(args[1].equals("list")&&args.length==2){var values=partyRegistrars.all();if(values.isEmpty())sender.sendMessage("No Party Registrars are registered.");else for(var value:values)sender.sendMessage(value.id()+" "+(value.name().isBlank()?"Party Registrar":value.name())+" @ "+value.world()+" "+value.x()+" "+value.y()+" "+value.z());return;}
@@ -1037,8 +1047,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void fireballConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = fireballSettings.value(key);
if (args.length == 2) {
sender.sendMessage(key + "=" + fireballSettings.value(key) + " (applies to future Fireball casts only)"); return;
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "fireball", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Fireball casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.FireballSettings.Update> operation;
try { operation = fireballSettings.set(key, args[2]); }
@@ -1052,6 +1065,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return;
if (failure == null && fireballSettings.publish(saved)) {
fireball.configure(fireballSettings.settings()); abilities.replace(fireball.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "fireball", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
@@ -1096,9 +1110,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
}
private void quickerRegenerationConfiguration(CommandSender sender, String[] args) {
String key=games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY;String before=quickerRegenerationSettings.value();String actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;
if (args.length == 2) {
sender.sendMessage(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY + "="
+ quickerRegenerationSettings.value() + " (applies to future recovery pulses only)"); return;
if(passiveEvents!=null)passiveEvents.configRead(actorKind,actorPlayer,actorName,key,before);
sender.sendMessage(key + "=" + before + " (applies to future recovery pulses only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.QuickerRegenerationSettings.Update> operation;
try { operation = quickerRegenerationSettings.set(args[2]); }
@@ -1111,7 +1126,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return; boolean applied = false;
if (failure == null) {
applied = quickerRegenerationSettings.publish(saved);
if (applied) { quickerRegeneration.configure(saved.settings()); passiveRegistry.replace(quickerRegeneration.definition()); }
if (applied) { quickerRegeneration.configure(saved.settings()); passiveRegistry.replace(quickerRegeneration.definition());if(passiveEvents!=null)passiveEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value()); }
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
@@ -1249,8 +1264,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void mendingLightConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = mendingLightSettings.value(key);
if (args.length == 2) {
sender.sendMessage(key + "=" + mendingLightSettings.value(key) + " (applies to future Mending Light casts only)");
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "mending_light", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Mending Light casts only)");
return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.MendingLightSettings.Update> operation;
@@ -1263,8 +1281,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
mendingLight.configure(saved.settings()); abilities.replace(mendingLight.definition()); mendingLightSettings.publish(saved);
if (failure == null && mendingLightSettings.publish(saved)) {
mendingLight.configure(saved.settings()); abilities.replace(mendingLight.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "mending_light", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
@@ -1277,8 +1296,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void purifyConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = purifySettings.value(key);
if (args.length == 2) {
sender.sendMessage(key + "=" + purifySettings.value(key) + " (applies to future Purify casts only)"); return;
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "purify", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Purify casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PurifySettings.Update> operation;
try { operation = purifySettings.set(key, args[2]); }
@@ -1290,8 +1312,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
purify.configure(saved.settings()); abilities.replace(purify.definition()); purifySettings.publish(saved);
if (failure == null && purifySettings.publish(saved)) {
purify.configure(saved.settings()); abilities.replace(purify.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "purify", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
@@ -1304,8 +1327,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void guardianBondConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = guardianBondSettings.value(key);
if (args.length == 2) {
sender.sendMessage(key + "=" + guardianBondSettings.value(key) + " (applies to future Guardian Bond casts only)"); return;
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "guardian_bond", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Guardian Bond casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.GuardianBondSettings.Update> operation;
try { operation = guardianBondSettings.set(key, args[2]); }
@@ -1317,8 +1343,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
guardianBond.configure(saved.settings()); abilities.replace(guardianBond.definition()); guardianBondSettings.publish(saved);
if (failure == null && guardianBondSettings.publish(saved)) {
guardianBond.configure(saved.settings()); abilities.replace(guardianBond.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "guardian_bond", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
@@ -1331,8 +1358,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void rallyingCryConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = rallyingCrySettings.value(key);
if (args.length == 2) {
sender.sendMessage(key + "=" + rallyingCrySettings.value(key) + " (applies to future Rallying Cry casts only)"); return;
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "rallying_cry", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Rallying Cry casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RallyingCrySettings.Update> operation;
try { operation = rallyingCrySettings.set(key, args[2]); }
@@ -1344,8 +1374,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
rallyingCry.configure(saved.settings()); abilities.replace(rallyingCry.definition()); rallyingCrySettings.publish(saved);
if (failure == null && rallyingCrySettings.publish(saved)) {
rallyingCry.configure(saved.settings()); abilities.replace(rallyingCry.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "rallying_cry", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
@@ -1358,8 +1389,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void frostNovaConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = frostNovaSettings.value(key);
if (args.length == 2) {
sender.sendMessage(key + "=" + frostNovaSettings.value(key) + " (applies to future Frost Nova casts only)"); return;
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "frost_nova", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Frost Nova casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.FrostNovaSettings.Update> operation;
try { operation = frostNovaSettings.set(key, args[2]); }
@@ -1371,12 +1405,14 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
frostNova.configure(saved.settings()); abilities.replace(frostNova.definition()); frostNovaSettings.publish(saved);
boolean published = failure == null && frostNovaSettings.publish(saved);
if (published) {
frostNova.configure(saved.settings()); abilities.replace(frostNova.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "frost_nova", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
sender.sendMessage(published
? "Saved " + key + "=" + saved.value() + " for future Frost Nova casts; active slows and deadlines are unchanged."
: "Frost Nova configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
@@ -1385,8 +1421,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void chainLightningConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = chainLightningSettings.value(key);
if (args.length == 2) {
sender.sendMessage(key + "=" + chainLightningSettings.value(key) + " (applies to future Chain Lightning casts only)"); return;
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "chain_lightning", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Chain Lightning casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.ChainLightningSettings.Update> operation;
try { operation = chainLightningSettings.set(key, args[2]); }
@@ -1398,12 +1437,14 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
chainLightning.configure(saved.settings()); abilities.replace(chainLightning.definition()); chainLightningSettings.publish(saved);
boolean published = failure == null && chainLightningSettings.publish(saved);
if (published) {
chainLightning.configure(saved.settings()); abilities.replace(chainLightning.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "chain_lightning", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
sender.sendMessage(published
? "Saved " + key + "=" + saved.value() + " for future Chain Lightning casts; active chains and deadlines are unchanged."
: "Chain Lightning configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
@@ -1412,8 +1453,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void sanctuaryConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = sanctuarySettings.value(key);
if (args.length == 2) {
sender.sendMessage(key + "=" + sanctuarySettings.value(key) + " (applies to future Sanctuary casts only)"); return;
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "sanctuary", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Sanctuary casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SanctuarySettings.Update> operation;
try { operation = sanctuarySettings.set(key, args[2]); }
@@ -1425,12 +1469,14 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
sanctuary.configure(saved.settings()); abilities.replace(sanctuary.definition()); sanctuarySettings.publish(saved);
boolean published = failure == null && sanctuarySettings.publish(saved);
if (published) {
sanctuary.configure(saved.settings()); abilities.replace(sanctuary.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "sanctuary", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
sender.sendMessage(published
? "Saved " + key + "=" + saved.value() + " for future Sanctuary casts; active areas and deadlines are unchanged."
: "Sanctuary configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
@@ -1439,7 +1485,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void warBannerConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) { sender.sendMessage(key + "=" + warBannerSettings.value(key) + " (applies to future War Banner casts only)"); return; }
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = warBannerSettings.value(key);
if (args.length == 2) {
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "war_banner", key, before);
sender.sendMessage(key + "=" + before + " (applies to future War Banner casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.WarBannerSettings.Update> operation;
try { operation = warBannerSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) { sender.sendMessage("War Banner setting is invalid; the effective value is unchanged."); return; }
@@ -1447,9 +1498,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (failure != null) report(null, "config-" + key, failure); if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) { warBanner.configure(saved.settings()); abilities.replace(warBanner.definition()); warBannerSettings.publish(saved); }
boolean published = failure == null && warBannerSettings.publish(saved);
if (published) {
warBanner.configure(saved.settings()); abilities.replace(warBanner.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "war_banner", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION) || sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
sender.sendMessage(published
? "Saved " + key + "=" + saved.value() + " for future War Banner casts; active areas and deadlines are unchanged."
: "War Banner configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
@@ -1457,10 +1512,35 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
}
private void meteorConfiguration(CommandSender sender, String[] args) {
String key=args[1];if(args.length==2){sender.sendMessage(key+"="+meteorSettings.value(key)+" (applies to future Meteor casts only)");return;}
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = meteorSettings.value(key);
if (args.length == 2) {
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "meteor", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Meteor casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.MeteorSettings.Update> operation;
try{operation=meteorSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Meteor 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){meteor.configure(saved.settings());abilities.replace(meteor.definition());meteorSettings.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 Meteor casts; active warnings and deadlines are unchanged.":"Meteor configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});
try { operation = meteorSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Meteor 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;
boolean published = failure == null && meteorSettings.publish(saved);
if (published) {
meteor.configure(saved.settings()); abilities.replace(meteor.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "meteor", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(published
? "Saved " + key + "=" + saved.value() + " for future Meteor casts; active warnings and deadlines are unchanged."
: "Meteor configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void hexConfiguration(CommandSender sender, String[] args) {
@@ -1555,13 +1635,13 @@ 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 partyEconomyConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+partyEconomySettings.value(key)+" (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(!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);}});}
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);}});}
private void gearConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+gearSettings.value(key)+" (applies to the next armor restock only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.GearSettings.Update> operation;try{operation=gearSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Armor market setting is invalid; variance is 0–100, prices are nonnegative, and positive stock minima cannot exceed maxima.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&gearSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Armor market setting save failed; effective settings and the current catalog are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+gearSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for the next automatic or administrative restock; the current catalog is unchanged.");});});}
private void rareConfiguration(CommandSender sender,String[] args){String key=args[1],before=rareSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;boolean phoenix=key.contains("phoenix-down");if(args.length==2){if(phoenix&&downedEvents!=null)downedEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+(key.endsWith("-healing-percent")||key.equals(games.dmg.fantasystick.persistence.RareSettings.PHOENIX_PROTECTION)?" (applies to future accepted uses)":" (applies to the next rare restock only)"));return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RareSettings.Update> operation;try{operation=rareSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Rare setting is invalid; percentages are 0–100, values are nonnegative, and stock minima cannot exceed maxima.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&rareSettings.publish(saved);if(applied&&phoenix&&downedEvents!=null)downedEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Rare setting save failed; prior effective state is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+rareSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+"; current catalog and accepted uses are unchanged.");});});}
private void downedConfiguration(CommandSender sender,String[] args){String key=args[1],before=downedSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;if(args.length==2){if(downedEvents!=null)downedEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+" (applies to future downed outcomes only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.DownedSettings.Update> operation;try{operation=downedSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Downed setting is invalid; timeout must be positive and protection may be zero, both at most 365 days.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&downedSettings.publish(saved);if(applied&&downedEvents!=null)downedEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Downed setting save failed; prior effective state is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+downedSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+"; existing deadlines are unchanged.");});});}
private void coinConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+coinSettings.value(key)+" (applies to future creation or defeats only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.CoinSettings.Update> operation;try{operation=coinSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Coin setting is invalid; ranges must be ordered whole numbers from 0 through 2147483647.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&coinSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Coin configuration save failed; effective ranges are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+coinSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future creation or defeats; existing balances and captured rewards are unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void coinConfiguration(CommandSender sender,String[] args){String key=args[1],before=coinSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;if(args.length==2){if(coinEvents!=null)coinEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+" (applies to future creation or defeats only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.CoinSettings.Update> operation;try{operation=coinSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Coin setting is invalid; ranges must be ordered whole numbers from 0 through 2147483647.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&coinSettings.publish(saved);if(applied&&coinEvents!=null)coinEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Coin configuration save failed; effective ranges are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+coinSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future creation or defeats; existing balances and captured rewards are unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void hardMobXpConfiguration(CommandSender sender, String[] args) {
String key = games.dmg.fantasystick.persistence.HardMobXpSettings.KEY;
@@ -47,6 +47,7 @@ public final class EquipmentSessions implements AutoCloseable {
private java.util.function.Function<UUID,DefeatCapture> defeatCapture=ignored->new DefeatCapture(false,List.of(),0,BigInteger.ZERO);
private java.util.function.Function<DownedEpisode,List<DownedEpisode.Reward>> downedRewardAllocation=ignored->List.of();
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
private games.dmg.fantasystick.events.SessionEventBridge sessionEvents;
private java.util.function.Function<DownedEpisode,CompletionStage<?>> downedDefeatRewards=ignored->CompletableFuture.completedStage(null);
private java.util.function.Predicate<UUID> unavailableCharacter = ignored -> false;
private java.util.function.Function<UUID,java.util.concurrent.CompletionStage<?>> partyDefeat =
@@ -114,6 +115,8 @@ public final class EquipmentSessions implements AutoCloseable {
boolean uncertainSave;
boolean nativeUncertain;
boolean exitRequested;
games.dmg.fantasystick.events.SessionEventBridge.ExitCapture exitEvent;
boolean exitEventWritten;
Duration exitDelay;
boolean arrival;
Attempt recoveryOwner;
@@ -206,6 +209,7 @@ public final class EquipmentSessions implements AutoCloseable {
public synchronized void attachDefeatCapture(java.util.function.Function<UUID,DefeatCapture> capture){if(!owned.isEmpty())throw new IllegalStateException("Sessions already started");defeatCapture=Objects.requireNonNull(capture);}
public synchronized void attachDownedRewardAllocation(java.util.function.Function<DownedEpisode,List<DownedEpisode.Reward>> allocation){if(!owned.isEmpty())throw new IllegalStateException("Sessions already started");downedRewardAllocation=Objects.requireNonNull(allocation);}
public synchronized void attachDownedEvents(games.dmg.fantasystick.events.DownedEventBridge events){if(!owned.isEmpty()||downedEvents!=null)throw new IllegalStateException("Downed events already attached or sessions started");downedEvents=Objects.requireNonNull(events);}
public synchronized void attachSessionEvents(games.dmg.fantasystick.events.SessionEventBridge events){if(!owned.isEmpty()||sessionEvents!=null)throw new IllegalStateException("Session events already attached or sessions started");sessionEvents=Objects.requireNonNull(events);}
public synchronized void attachDownedDefeatRewards(java.util.function.Function<DownedEpisode,CompletionStage<?>> settlement){if(!owned.isEmpty())throw new IllegalStateException("Sessions already started");downedDefeatRewards=Objects.requireNonNull(settlement);}
public synchronized List<DownedEpisode.Reward> finalRewards(DownedEpisode episode){return List.copyOf(downedRewardAllocation.apply(Objects.requireNonNull(episode)));}
@@ -703,6 +707,7 @@ public final class EquipmentSessions implements AutoCloseable {
if(attempt.resumedRevive!=null){var recovered=attempt.resumedRevive;if(downedEvents!=null)downedEvents.outcomePublished(recovered);downedDirectory.publish(recovered.id(),recovered.outcomeOperation()).whenComplete((published,publishFailure)->{if(publishFailure!=null)diagnostic(attempt,publishFailure);else if(downedEvents!=null)downedEvents.outcomePublished(published);});}
}
attempt.player.updateInventory();
if(active.get(attempt.owner)==attempt&&sessionEvents!=null)sessionEvents.entered(attempt.player,attempt.session,record,attempt.entryLocation,attempt.resumedDowned!=null,attempt.previousGameMode!=null);
} catch (RuntimeException error) {
fail(attempt, error); // Effects may have started: never roll back or replay them.
notice(attempt.owner, "NATIVE_UNCERTAIN", "Equipment mutation failed; items and receipt require inspection.");
@@ -849,7 +854,7 @@ public final class EquipmentSessions implements AutoCloseable {
public synchronized void reviveDowned(DownedEpisode episode,Consumer<Boolean> completion){Objects.requireNonNull(episode);Objects.requireNonNull(completion);if(episode.phase()!=DownedEpisode.Phase.RESOLVED||episode.outcome()!=DownedEpisode.Outcome.REVIVED){completion.accept(false);return;}var attempt=active.get(episode.player());if(attempt==null||attempt.downed==null||!attempt.downed.id().equals(episode.id())||!attempt.session.equals(episode.session())||!attempt.character.equals(episode.character())){completion.accept(false);return;}var expected=attempt.record.revision();var maximum=attempt.record.maximumHealth().min(NativeHealthLimit.read());characters.saveHealth(attempt.character,expected,maximum,maximum).whenComplete((saved,failure)->persistReturn(()->{synchronized(EquipmentSessions.this){if(failure!=null||saved==null||saved.isEmpty()||active.get(attempt.owner)!=attempt||attempt.downed==null||!attempt.downed.id().equals(episode.id())){if(failure!=null)diagnostic(attempt,failure);completion.accept(false);return;}var record=saved.orElseThrow();try{if(vitals!=null&&!record.revision().equals(attempt.record.revision()))vitals.updateProgress(attempt.player,attempt.session,attempt.record,record);attempt.record=record;attempt.downed=null;attempt.downing=false;attempt.downedProtectionUntil=episode.protectionUntil();double nativeMaximum=Objects.requireNonNull(attempt.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue();attempt.player.setHealth(Math.min(nativeMaximum,record.health().doubleValue()));refreshControls(attempt,new Active(attempt.owner,attempt.session,attempt.character));attempt.player.closeInventory();notice(attempt.owner,"REVIVED","§aRevive restored your downed character.");completion.accept(true);}catch(RuntimeException publicationFailure){diagnostic(attempt,publicationFailure);completion.accept(false);}}}));}
/** Publish one already-durable final-death outcome through the existing restoration/Tail custody path. */
public synchronized boolean finalizeDowned(DownedEpisode episode){Objects.requireNonNull(episode);if(episode.phase()!=DownedEpisode.Phase.RESOLVED||episode.outcome()!=DownedEpisode.Outcome.FINAL_DEATH)return false;var attempt=active.get(episode.player());if(attempt==null){persist(()->recordOfflineFinalDeath(episode),(dead,failure)->{if(failure!=null)diagnostic(new Failure(episode.player(),episode.session(),failure));else if(dead.isEmpty())diagnostic(new Failure(episode.player(),episode.session(),new IllegalStateException("Offline final death conflicted with saved character")));});return true;}if(attempt.downed==null||!attempt.downed.id().equals(episode.id())||!attempt.session.equals(episode.session())||!attempt.character.equals(episode.character()))return false;active.remove(attempt.owner,attempt);attempt.finalDowned=episode;attempt.downed=null;withdrawPresentation(attempt);attempt.defeated=true;boolean reserved=transitioning.putIfAbsent(attempt.owner,attempt)==null;boolean loseCoins=episode.attribution().playerDefeat();persist(()->characters.recordFinalDeath(attempt.character,attempt.record.revision(),episode.id(),episode.resolvedAt(),loseCoins),(dead,failure)->{if(failure!=null){diagnostic(attempt,failure);conversionUncertain(attempt,"Final death save failed or is uncertain");return;}if(dead.isEmpty()){conversionUncertain(attempt,"Character was missing or final death conflicted; no Tail authorized");return;}attempt.deathResolved=true;attempt.deathRecord=dead.orElseThrow();if(downedEvents!=null)downedEvents.finalDeathRecorded(episode);if(closed){conversionUncertain(attempt,"Shutdown before reward, party defeat and Tail conversion");return;}java.util.concurrent.CompletionStage<?> partyMutation=finalConsequences(episode);partyMutation.whenComplete((ignored,partyFailure)->{synchronized(EquipmentSessions.this){if(partyFailure!=null){diagnostic(attempt,partyFailure);conversionUncertain(attempt,"Party defeat mutation failed");return;}attempt.partyDefeatAcknowledged=true;queueTail(attempt);}});});try{var restored=restoreExit(attempt);if(restored==EquipmentStorage.RestoreResult.RESTORED){var retained=attempt.player.getInventory().getItemInOffHand();attempt.deathReference=copy(retained);attempt.deathNativeReference=nativeIdentity(retained);attempt.deathView=attempt.player.getOpenInventory();}double maximum=Objects.requireNonNull(attempt.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue();attempt.player.setHealth(maximum);notice(attempt.owner,"DEFEAT","§cYour character was defeated.");attempt.defeatReturnTeleport=true;try{if(!attempt.player.teleport(attempt.entryLocation.clone()))notice(attempt.owner,"DEFEAT_RETURN_FAILED","Return to the session entry location was refused; you remain here.");}finally{attempt.defeatReturnTeleport=false;if(!attempt.deliveryInvalidated&&owned.get(attempt.owner)==attempt&&attempt.player.isOnline())attempt.deathView=attempt.player.getOpenInventory();}}catch(RuntimeException failure){diagnostic(attempt,failure);conversionUncertain(attempt,"Native final-death transition failed; inspect retained equipment and death state");}finally{if(reserved)transitioning.remove(attempt.owner,attempt);finishDrain();}return true;}
public synchronized boolean finalizeDowned(DownedEpisode episode){Objects.requireNonNull(episode);if(episode.phase()!=DownedEpisode.Phase.RESOLVED||episode.outcome()!=DownedEpisode.Outcome.FINAL_DEATH)return false;var attempt=active.get(episode.player());if(attempt==null){persist(()->recordOfflineFinalDeath(episode),(dead,failure)->{if(failure!=null)diagnostic(new Failure(episode.player(),episode.session(),failure));else if(dead.isEmpty())diagnostic(new Failure(episode.player(),episode.session(),new IllegalStateException("Offline final death conflicted with saved character")));});return true;}if(attempt.downed==null||!attempt.downed.id().equals(episode.id())||!attempt.session.equals(episode.session())||!attempt.character.equals(episode.character()))return false;active.remove(attempt.owner,attempt);attempt.finalDowned=episode;attempt.downed=null;withdrawPresentation(attempt);attempt.defeated=true;boolean reserved=transitioning.putIfAbsent(attempt.owner,attempt)==null;boolean loseCoins=episode.attribution().playerDefeat();persist(()->characters.recordFinalDeath(attempt.character,attempt.record.revision(),episode.id(),episode.resolvedAt(),loseCoins),(dead,failure)->{if(failure!=null){diagnostic(attempt,failure);conversionUncertain(attempt,"Final death save failed or is uncertain");return;}if(dead.isEmpty()){conversionUncertain(attempt,"Character was missing or final death conflicted; no Tail authorized");return;}attempt.deathResolved=true;attempt.deathRecord=dead.orElseThrow();if(downedEvents!=null)downedEvents.finalDeathRecorded(episode);if(closed){conversionUncertain(attempt,"Shutdown before reward, party defeat and Tail conversion");return;}java.util.concurrent.CompletionStage<?> partyMutation=finalConsequences(episode);partyMutation.whenComplete((ignored,partyFailure)->{synchronized(EquipmentSessions.this){if(partyFailure!=null){diagnostic(attempt,partyFailure);conversionUncertain(attempt,"Party defeat mutation failed");return;}attempt.partyDefeatAcknowledged=true;queueTail(attempt);}});});try{var restored=restoreExit(attempt);if(restored==EquipmentStorage.RestoreResult.RESTORED){var retained=attempt.player.getInventory().getItemInOffHand();attempt.deathReference=copy(retained);attempt.deathNativeReference=nativeIdentity(retained);attempt.deathView=attempt.player.getOpenInventory();}double maximum=Objects.requireNonNull(attempt.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue();attempt.player.setHealth(maximum);notice(attempt.owner,"DEFEAT","§cYour character was defeated.");attempt.defeatReturnTeleport=true;try{boolean returned;try{returned=attempt.player.teleport(attempt.entryLocation.clone());}catch(RuntimeException uncertain){if(downedEvents!=null)downedEvents.returnAttempted(episode,"uncertain");throw uncertain;}if(downedEvents!=null)downedEvents.returnAttempted(episode,returned?"returned":"refused");if(!returned)notice(attempt.owner,"DEFEAT_RETURN_FAILED","Return to the session entry location was refused; you remain here.");}finally{attempt.defeatReturnTeleport=false;if(!attempt.deliveryInvalidated&&owned.get(attempt.owner)==attempt&&attempt.player.isOnline())attempt.deathView=attempt.player.getOpenInventory();}}catch(RuntimeException failure){diagnostic(attempt,failure);conversionUncertain(attempt,"Native final-death transition failed; inspect retained equipment and death state");}finally{if(reserved)transitioning.remove(attempt.owner,attempt);finishDrain();}return true;}
private CompletionStage<Void> finalConsequences(DownedEpisode episode){var result=new CompletableFuture<Void>();try{nextTick.accept(()->{CompletionStage<?> work;try{work=downedDefeatRewards.apply(episode).thenCompose(ignored->partyDefeat.apply(episode.character()));}catch(RuntimeException failure){result.completeExceptionally(failure);return;}work.whenComplete((ignored,failure)->{if(failure==null)result.complete(null);else result.completeExceptionally(failure);});});}catch(RuntimeException rejected){result.completeExceptionally(rejected);}return result;}
@@ -985,6 +990,7 @@ public final class EquipmentSessions implements AutoCloseable {
private void withdrawPresentation(Attempt attempt) {
if (!attempt.presentationInstalled) return;
attempt.presentationInstalled = false;
if(sessionEvents!=null)sessionEvents.forget(attempt.owner,attempt.session);
try { presentation.withdraw(attempt.owner, attempt.session); }
catch (RuntimeException | LinkageError failure) { diagnostic(attempt, failure); }
}
@@ -1002,6 +1008,8 @@ public final class EquipmentSessions implements AutoCloseable {
}
private EquipmentStorage.RestoreResult restoreExit(Attempt attempt) {
if(sessionEvents!=null&&attempt.exitEvent==null&&!attempt.defeated&&attempt.downed==null&&!closed&&attempt.player.isOnline()&&attempt.record!=null)
attempt.exitEvent=sessionEvents.captureExit(attempt.player,attempt.session,attempt.record,attempt.entryLocation);
captureExitDelay(attempt);
active.remove(attempt.owner, attempt);
withdrawPresentation(attempt);
@@ -1122,6 +1130,10 @@ public final class EquipmentSessions implements AutoCloseable {
"Equipment restoration persistence failed before Tail conversion");
} else if (complete) {
attempt.gearAcknowledged = true;
if (!attempt.defeated && !attempt.exitEventWritten && attempt.exitEvent != null && sessionEvents != null) {
attempt.exitEventWritten = true;
sessionEvents.exited(attempt.session,attempt.exitEvent);
}
if (attempt.defeated) queueTail(attempt);
release(attempt);
} else if (attempt.defeated) {
@@ -15,8 +15,13 @@ public final class FantasyFeedbackCommand implements TabExecutor, AutoCloseable
private final PlayerFeedbackPreferences preferences;
private final Consumer<Runnable> nextTick;
private final Consumer<Throwable> failures;
private games.dmg.fantasystick.events.PreferenceEventBridge events;
private boolean closed;
public void attachEvents(games.dmg.fantasystick.events.PreferenceEventBridge bridge) {
this.events = Objects.requireNonNull(bridge);
}
public FantasyFeedbackCommand(PlayerFeedbackPreferences preferences, Consumer<Runnable> nextTick,
Consumer<Throwable> failures) {
this.preferences = Objects.requireNonNull(preferences);
@@ -26,32 +31,51 @@ public final class FantasyFeedbackCommand implements TabExecutor, AutoCloseable
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player player)) {
if (events != null) try { events.refuseNonPlayer(sender, "combat_feedback"); }
catch (RuntimeException ignored) { }
sender.sendMessage("Fantasy feedback is a player-account preference.");
return true;
}
if (args.length == 0) {
var action = observe(player, null);
player.sendMessage("Fantasy feedback is " + state(preferences.enabled(player.getUniqueId())) + ".");
if (action != null) events.read(action);
return true;
}
if (args.length != 1 || !(args[0].equals("on") || args[0].equals("off"))) return false;
if (args.length != 1 || !(args[0].equals("on") || args[0].equals("off"))) {
var action = observe(player, null);
if (action != null) events.refused(action, "invalid_arguments");
return false;
}
boolean enabled = args[0].equals("on");
var account = player.getUniqueId();
preferences.set(account, enabled).whenComplete((saved, failure) -> {
var action = observe(player, enabled);
var save = action == null ? preferences.set(account, enabled)
: preferences.set(account, enabled, events.intent(action));
save.whenComplete((saved, failure) -> {
if (failure != null) report(failure);
try {
nextTick.accept(() -> {
if (closed) return;
if (closed) {
if (action != null) events.failed(action, "closed");
return;
}
if (failure != null) {
if (action != null) events.failed(action, "save_failed");
if (player.isOnline()) player.sendMessage(
"Fantasy feedback preference could not be saved; it remains "
+ state(preferences.enabled(account)) + ".");
return;
}
boolean published = preferences.publish(saved);
if (action != null) events.saved(action, saved, published, preferences.enabled(account));
if (player.isOnline()) player.sendMessage("Fantasy feedback is "
+ (published ? "now " : "already ") + state(preferences.enabled(account)) + ".");
});
} catch (RuntimeException rejected) { report(rejected); }
} catch (RuntimeException rejected) {
if (action != null) events.failed(action, "schedule_failed");
report(rejected);
}
});
return true;
}
@@ -62,6 +86,13 @@ public final class FantasyFeedbackCommand implements TabExecutor, AutoCloseable
return List.of("off", "on").stream().filter(value -> value.startsWith(prefix)).toList();
}
private games.dmg.fantasystick.events.PreferenceEventBridge.Action observe(Player player, Boolean requested) {
var bridge = events;
if (bridge == null) return null;
try { return bridge.begin(player, "combat_feedback", preferences.enabled(player.getUniqueId()), requested); }
catch (RuntimeException ignored) { return null; } // Auditing cannot refuse a player preference.
}
private static String state(boolean enabled) { return enabled ? "on" : "off"; }
private void report(Throwable failure) { try { failures.accept(failure); } catch (RuntimeException ignored) { } }
@Override public void close() { closed = true; }
@@ -16,8 +16,13 @@ public final class FantasyParticlesCommand implements TabExecutor, AutoCloseable
private final PlayerFeedbackPreferences preferences;
private final Consumer<Runnable> nextTick;
private final Consumer<Throwable> failures;
private games.dmg.fantasystick.events.PreferenceEventBridge events;
private boolean closed;
public void attachEvents(games.dmg.fantasystick.events.PreferenceEventBridge events) {
this.events = Objects.requireNonNull(events);
}
public FantasyParticlesCommand(PlayerFeedbackPreferences preferences, Consumer<Runnable> nextTick,
Consumer<Throwable> failures) {
this.preferences = Objects.requireNonNull(preferences);
@@ -27,35 +32,60 @@ public final class FantasyParticlesCommand implements TabExecutor, AutoCloseable
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player player)) {
if (args.length > 0 && (args[0].equals("all") || args[0].equals("players")) && events != null) {
try { events.refuseNonPlayer(sender, key(args[0])); } catch (RuntimeException ignored) { }
}
sender.sendMessage("Fantasy particles are a player-account preference.");
return true;
}
if (args.length == 1 && (args[0].equals("players") || args[0].equals("all"))) {
var action = observe(player, args[0], null);
player.sendMessage(status(player.getUniqueId(), args[0]));
if (action != null) events.read(action);
return true;
}
if (args.length != 2 || !(args[0].equals("players") || args[0].equals("all"))
|| !(args[1].equals("on") || args[1].equals("off"))) return false;
|| !(args[1].equals("on") || args[1].equals("off"))) {
if (args.length > 0 && (args[0].equals("all") || args[0].equals("players"))) {
var action = observe(player, args[0], null);
if (action != null) events.refused(action, "invalid_arguments");
}
return false;
}
String route = args[0];
boolean enabled = args[1].equals("on");
UUID account = player.getUniqueId();
var action = observe(player, route, enabled);
var save = route.equals("players")
? preferences.setPlayerAuras(account, enabled) : preferences.setAllParticles(account, enabled);
? action == null ? preferences.setPlayerAuras(account, enabled)
: preferences.setPlayerAuras(account, enabled, events.intent(action))
: action == null ? preferences.setAllParticles(account, enabled)
: preferences.setAllParticles(account, enabled, events.intent(action));
save.whenComplete((saved, failure) -> {
if (failure != null) report(failure);
try {
nextTick.accept(() -> {
if (closed) return;
if (closed) {
if (action != null) events.failed(action, "closed");
return;
}
if (failure != null) {
if (action != null) events.failed(action, "save_failed");
if (player.isOnline()) player.sendMessage(
(route.equals("players") ? "Active-character aura" : "All-particles")
+ " preference could not be saved; " + status(account, route));
return;
}
boolean published = preferences.publish(saved);
if (action != null) events.saved(action, saved, published,
route.equals("players") ? preferences.auraParticlesEnabled(account)
: preferences.allParticlesEnabled(account));
if (player.isOnline()) player.sendMessage(acknowledgement(account, route, published));
});
} catch (RuntimeException rejected) { report(rejected); }
} catch (RuntimeException rejected) {
if (action != null) events.failed(action, "schedule_failed");
report(rejected);
}
});
return true;
}
@@ -68,6 +98,18 @@ public final class FantasyParticlesCommand implements TabExecutor, AutoCloseable
return List.of();
}
private games.dmg.fantasystick.events.PreferenceEventBridge.Action observe(
Player player, String route, Boolean requested) {
var bridge = events;
if (bridge == null) return null;
try { return bridge.begin(player, key(route), route.equals("players")
? preferences.auraParticlesEnabled(player.getUniqueId())
: preferences.allParticlesEnabled(player.getUniqueId()), requested); }
catch (RuntimeException ignored) { return null; } // Capture failure cannot refuse gameplay.
}
private static String key(String route) { return route.equals("players") ? "player-aura-particles" : "all-particles"; }
private String status(UUID account, String route) {
if (route.equals("all"))
return "All Fantasy Stick particles are " + state(preferences.allParticlesEnabled(account)) + ".";
@@ -66,6 +66,8 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
private final CombatFeedback feedback;
private final CombatSources sources;
private java.util.function.Function<CharacterRecord, BigInteger> bonusHealing = ignored -> BigInteger.ZERO;
private java.util.function.Function<CharacterRecord, BigInteger> quickerRegenerationContribution = ignored -> BigInteger.ZERO;
private games.dmg.fantasystick.events.RecoveryEventBridge recoveryEvents;
private final Map<UUID, State> states = new HashMap<>();
private final ArrayDeque<UUID> order = new ArrayDeque<>();
private final Set<UUID> queued = new HashSet<>();
@@ -96,6 +98,7 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
if (closed || task != null || !states.isEmpty()) throw new IllegalStateException("Recovery evaluation already started");
bonusHealing = Objects.requireNonNull(bonus);
}
public synchronized void attachEvents(games.dmg.fantasystick.events.RecoveryEventBridge events,java.util.function.Function<CharacterRecord,BigInteger> quickerContribution){if(closed||recoveryEvents!=null)throw new IllegalStateException("Recovery events already attached or recovery closed");recoveryEvents=Objects.requireNonNull(events);quickerRegenerationContribution=Objects.requireNonNull(quickerContribution);}
public synchronized void install(org.bukkit.plugin.Plugin plugin) {
Objects.requireNonNull(plugin);
@@ -177,18 +180,19 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
state.lastAttempt = now; // Full-health checks are not banked for later replay.
return;
}
BigInteger exactAmount;
try { exactAmount = configured.amount().add(Objects.requireNonNull(bonusHealing.apply(record))); }
BigInteger exactAmount;BigInteger quickerContribution;
try { exactAmount = configured.amount().add(Objects.requireNonNull(bonusHealing.apply(record)));quickerContribution=Objects.requireNonNull(quickerRegenerationContribution.apply(record)); }
catch (RuntimeException failure) { state.lastAttempt = now; report(record.id(), failure); return; }
if (exactAmount.signum() < 0) { state.lastAttempt = now; report(record.id(), new IllegalStateException("Negative recovery bonus")); return; }
double amount = exactAmount.bitLength() > 1023 ? maximum - current : exactAmount.doubleValue();
double proposed = Math.min(maximum - current, amount);
if (!Double.isFinite(proposed) || proposed <= 0) return;
var audit=recoveryEvents==null?null:recoveryEvents.begin(player,state.active,record,current,proposed,configured.amount(),quickerContribution);
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
healingEvents.accept(event);
try{healingEvents.accept(event);}catch(RuntimeException failure){if(audit!=null)recoveryEvents.failed(audit,"native_healing_event_failed");throw failure;}
double accepted = Math.min(proposed, Math.min(maximum - current, event.getAmount()));
state.lastAttempt = now;
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) return;
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) {if(audit!=null)recoveryEvents.refused(audit,event.isCancelled()?"native_event_cancelled":"no_positive_healing_accepted");return;}
Object token = new Object();
state.pending = token;
long generation = state.generation;
@@ -196,44 +200,45 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
BigDecimal target = BigDecimal.valueOf(current + accepted);
BigDecimal limit;
try { limit = nativeLimit.get(); }
catch (RuntimeException failure) { state.pending = null; state.lastAttempt = clock.instant(); report(record.id(), failure); return; }
catch (RuntimeException failure) { state.pending = null; state.lastAttempt = clock.instant();if(audit!=null)recoveryEvents.failed(audit,"native_health_limit_unavailable"); report(record.id(), failure); return; }
characters.saveHealth(record.id(), expected, target, limit).whenComplete((saved, failure) -> {
try {
nextTick.accept(() -> complete(state, token, generation, expected, current, saved, failure));
nextTick.accept(() -> complete(state, token, generation, expected, current, configured.amount(), quickerContribution, audit, saved, failure));
} catch (RuntimeException rejected) {
synchronized (OutOfCombatRecovery.this) {
if (state.pending == token) { state.pending = null; state.lastAttempt = clock.instant(); }
}
report(record.id(), rejected);
if(audit!=null)recoveryEvents.failed(audit,"scheduler_rejected_after_save_uncertain");report(record.id(), rejected);
}
});
}
private synchronized void complete(State state, Object token, long generation, BigInteger expected,
double nativeBefore, java.util.Optional<CharacterRecord> saved, Throwable failure) {
double nativeBefore, BigInteger configuredBase,BigInteger quickerConfigured,games.dmg.fantasystick.events.RecoveryEventBridge.Action audit,java.util.Optional<CharacterRecord> saved, Throwable failure) {
if (state.pending != token) return;
state.pending = null;
if (failure != null || saved == null || saved.isEmpty()) {
state.lastAttempt = clock.instant();
report(state.active.character(), failure != null ? failure : new IllegalStateException("Character unavailable for recovery save"));
if(audit!=null)recoveryEvents.failed(audit,failure!=null?"health_save_failed":"character_unavailable");report(state.active.character(), failure != null ? failure : new IllegalStateException("Character unavailable for recovery save"));
return;
}
var persisted = saved.orElseThrow();
boolean acknowledged = sessions.acknowledgeProgress(state.active, expected, persisted);
if (!acknowledged) {
report(state.active.character(), new IllegalStateException("Saved recovery could not synchronize with its active session"));
if(audit!=null)recoveryEvents.failed(audit,"saved_but_session_publication_refused");report(state.active.character(), new IllegalStateException("Saved recovery could not synchronize with its active session"));
return;
}
if (closed || generation != state.generation || states.get(state.active.player()) != state
|| !sessions.current(state.active)) return;
|| !sessions.current(state.active)) {if(audit!=null)recoveryEvents.failed(audit,"saved_but_session_changed_before_native_heal");return;}
var player = sessions.currentPlayer(state.active).orElse(null);
if (player == null || Double.compare(player.getHealth(), nativeBefore) != 0) return;
if (player == null || Double.compare(player.getHealth(), nativeBefore) != 0) {if(audit!=null)recoveryEvents.failed(audit,"saved_but_native_health_changed");return;}
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) return;
if (attribute == null) {if(audit!=null)recoveryEvents.failed(audit,"saved_but_maximum_health_unavailable");return;}
double maximum = Math.min(attribute.getValue(), persisted.maximumHealth().doubleValue());
double publish = Math.min(maximum, persisted.health().doubleValue());
if (!Double.isFinite(maximum) || !Double.isFinite(publish) || publish <= nativeBefore) return;
player.setHealth(publish);
if (!Double.isFinite(maximum) || !Double.isFinite(publish) || publish <= nativeBefore) {if(audit!=null)recoveryEvents.failed(audit,"saved_but_no_native_heal_available");return;}
try{player.setHealth(publish);}catch(RuntimeException nativeFailure){if(audit!=null)recoveryEvents.failed(audit,"native_health_publication_failed");throw nativeFailure;}
if(audit!=null){var total=BigDecimal.valueOf(publish-nativeBefore);var base=total.min(new BigDecimal(configuredBase));var passive=total.subtract(base).min(new BigDecimal(quickerConfigured)).max(BigDecimal.ZERO);recoveryEvents.healed(audit,BigDecimal.valueOf(publish),total,base,passive);}
if (feedback != null) feedback.report("Out-of-Combat Recovery", player, AbilityReport.Type.HEALING, player,
BigDecimal.valueOf(publish - nativeBefore).stripTrailingZeros());
}
@@ -77,6 +77,11 @@ class AbilityEngineNativeTest {
(CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"), sessions, parties,
java.time.Clock.fixed(java.time.Instant.parse("2026-09-19T20:00:00Z"), java.time.ZoneOffset.UTC),
f.tasks::add, scheduler, reports::add, failure -> fail(failure.toString()));
var captured = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
engine.attachCastEvents(new games.dmg.fantasystick.events.AbilityCastEventBridge(
event -> { if (event.type().equals("action.observed")) throw new IllegalStateException("test writer unavailable");
captured.add(event); }, java.time.Clock.systemUTC()));
engine.enableCastEventsFor("area_test"); engine.enableCastEventsFor("selection_test");
var active = sessions.active(a.core.id).orElseThrow();
var hostile = org.mockito.Mockito.mock(org.bukkit.entity.LivingEntity.class);
org.mockito.Mockito.when(hostile.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
@@ -85,6 +90,7 @@ class AbilityEngineNativeTest {
reports.stream().map(games.dmg.fantasystick.ability.AbilityReport::type).collect(java.util.stream.Collectors.toSet()));
assertEquals(5, reports.size());
assertEquals(a.core.record.id(), reports.getFirst().sourceCharacter());
assertTrue(captured.isEmpty(), "Unselected real abilities and reports cannot claim event delivery");
var ordinary = org.mockito.Mockito.mock(org.bukkit.entity.Player.class);
org.mockito.Mockito.when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
@@ -109,11 +115,18 @@ class AbilityEngineNativeTest {
assertEquals(0, delayedEffects.get(), "Party eligibility is rechecked at delayed application");
active = sessions.active(a.core.id).orElseThrow();
cast(engine, f, a, active, 5, "selection_test", b.player);
cast(engine, f, a, active, 5, "selection_test", null);
assertEquals(1, delayed.size());
var selectionCast = captured.stream().filter(event -> event.type().equals("ability.cast")
&& event.data().get("ability_id").equals("selection_test")).findFirst().orElseThrow();
assertEquals(active.session(), selectionCast.context().sessionId());
engine.remove(a.core.record.id(), "selection_test");
delayed.removeFirst().run();
assertEquals(0, selectionEffects.get(), "Removal invalidates pending selection callbacks without clearing cooldowns");
assertEquals(1, captured.stream().filter(event -> event.type().equals("ability.resolved")
&& event.actionId().equals(selectionCast.actionId()) && event.data().get("outcome").equals("interrupted")).count());
assertTrue(captured.stream().noneMatch(event -> event.type().equals("effect.applied")),
"No-op casts and unused reports cannot invent an effect");
var stored = ((CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"))
.find(a.core.record.id()).get().orElseThrow();
assertTrue(stored.cooldowns().containsKey("selection_test"));
@@ -138,7 +151,14 @@ class AbilityEngineNativeTest {
List.of("A registered test effect"), "Use the equipped shard", Map.of(), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, Duration.ofSeconds(4),
Map.of("range", BigDecimal.valueOf(12)), AbilityDefinition.TargetPolicy.SELF,
execution -> { assertTrue(execution.stillCurrent()); effects.incrementAndGet(); }));
execution -> {
assertTrue(execution.stillCurrent());
effects.incrementAndGet(); // Test-defined native execution, not a real spell.
execution.effectApplied("test_focus_effect", null, Map.of("native_applications", 1));
}));
var engine = (games.dmg.fantasystick.ability.AbilityEngine)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityEngine");
engine.enableCastEventsFor("test_focus");
var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0);
@@ -201,6 +221,82 @@ class AbilityEngineNativeTest {
assertEquals(1, effects.get(), "Exit/resume cannot reset the persisted character cooldown");
assertEquals(cooled.cooldowns(), storage.find(a.core.record.id()).get().orElseThrow().cooldowns());
}
var events = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(file))
if (!line.isBlank()) events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
for (var suffix : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved",
"effect.applied", "effect.ended")) {
assertEquals(1, events.stream().filter(event -> event.get("type").getAsString().endsWith(suffix)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("test_focus")).count(), suffix);
}
var castEvent = events.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("test_focus")).findFirst().orElseThrow();
assertEquals("test_focus", castEvent.getAsJsonObject("data").get("ability_id").getAsString());
assertTrue(castEvent.getAsJsonObject("data").has("cooldown_until"));
var applied = events.stream().filter(event -> event.get("type").getAsString().endsWith("effect.applied")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("test_focus")).findFirst().orElseThrow();
var ended = events.stream().filter(event -> event.get("type").getAsString().endsWith("effect.ended")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("test_focus")).findFirst().orElseThrow();
assertEquals(applied.getAsJsonObject("data").get("application_id").getAsString(),
ended.getAsJsonObject("data").get("application_id").getAsString());
assertEquals("1", applied.getAsJsonObject("data").get("native_applications").getAsString());
assertTrue(events.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved"))
.anyMatch(event -> event.getAsJsonObject("data").get("action_id").getAsString()
.equals(castEvent.getAsJsonObject("data").get("action_id").getAsString())));
}
@Test void failedCooldownSaveOfRegisteredDefinitionNeverClaimsACastOrEffect() {
var registry = new AbilityRegistry();
var effects = new AtomicInteger();
registry.register(new AbilityDefinition("test_failure", "Test Failure", Material.AMETHYST_SHARD,
List.of("A test-only ability"), "Use the control", Map.of(), BigInteger.ONE,
AbilityDefinition.Category.BUF, AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE,
Duration.ofSeconds(4), Map.of(), AbilityDefinition.TargetPolicy.SELF,
execution -> effects.incrementAndGet()));
var original = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var record = new games.dmg.fantasystick.persistence.CharacterRecord(original.id(), original.name(),
original.revision(), original.stats(), original.level(), original.experience(), original.unspentPoints(),
original.health(), java.util.Set.of("test_failure"), List.of("test_failure", "", "", "", "", "", ""),
original.cooldowns(), original.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 = org.mockito.Mockito.mock(org.bukkit.entity.Player.class);
var world = org.mockito.Mockito.mock(org.bukkit.World.class);
org.mockito.Mockito.when(player.getUniqueId()).thenReturn(active.player());
org.mockito.Mockito.when(player.getName()).thenReturn("Account");
org.mockito.Mockito.when(world.getUID()).thenReturn(java.util.UUID.randomUUID());
org.mockito.Mockito.when(world.getName()).thenReturn("world");
org.mockito.Mockito.when(player.getLocation()).thenReturn(new org.bukkit.Location(world, 1, 64, 2));
var sessions = org.mockito.Mockito.mock(games.dmg.fantasystick.session.EquipmentSessions.class);
org.mockito.Mockito.when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
var storage = org.mockito.Mockito.mock(CharacterStorage.class);
var failed = java.util.concurrent.CompletableFuture
.<CharacterStorage.AbilityMutation>failedFuture(new java.io.IOException("simulated save failure"));
org.mockito.Mockito.when(storage.commitAbilityCooldown(org.mockito.ArgumentMatchers.eq(record.id()),
org.mockito.ArgumentMatchers.eq(record.revision()), org.mockito.ArgumentMatchers.eq("test_failure"),
org.mockito.ArgumentMatchers.any())).thenReturn(failed);
var parties = new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(), ignored -> true);
var events = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
var failures = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityEngine.Failure>();
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties,
java.time.Clock.systemUTC(), Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failures::add);
engine.attachCastEvents(new games.dmg.fantasystick.events.AbilityCastEventBridge(events::add,
java.time.Clock.systemUTC()));
engine.enableCastEventsFor("test_failure");
engine.accept(new ControlInput(player, active, 2, "test_failure", ControlInput.Kind.ABILITY,
ControlInput.Gesture.USE, null, 0, 0, () -> true));
assertEquals(List.of("action.observed", "ability.resolved"), events.stream().map(
games.dmg.fantasystick.events.EventInput::type).toList());
assertEquals("failed", events.getLast().data().get("outcome"));
assertEquals(events.getFirst().actionId(), events.getLast().actionId());
assertEquals(0, effects.get());
assertEquals(1, failures.size());
engine.close(); parties.close();
}
private static AbilityDefinition definition(String id, AbilityDefinition.Activation activation,
@@ -67,6 +67,69 @@ class ChainLightningNativeTest {
assertEquals(BigInteger.valueOf(39), stored.unspentPoints()); assertTrue(stored.learned().contains("chain_lightning"));
assertEquals("chain_lightning", stored.loadout().getFirst()); assertNotNull(stored.cooldowns().get("chain_lightning"));
}
var records = events(data);
for (var type : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved"))
assertEquals(1, abilityEvents(records, type).size(), type);
var hits = abilityEvents(records, "combat.damage");
assertEquals(2, hits.size(), "Exactly one positive accepted record per hop");
assertEquals(2, records.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count(),
"The generic native observer must not repeat feature-owned chain hits");
assertEquals(List.of(1, 2), hits.stream().map(event -> event.getAsJsonObject("data").get("hop_ordinal").getAsInt()).toList());
assertEquals(2, hits.stream().map(event -> event.getAsJsonObject("data").get("application_id").getAsString()).distinct().count());
assertEquals(13, hits.getFirst().getAsJsonObject("data").get("actual_amount").getAsDouble(), 1.0e-9);
assertEquals(9.75, hits.getLast().getAsJsonObject("data").get("actual_amount").getAsDouble(), 1.0e-9);
assertTrue(hits.stream().allMatch(event -> event.getAsJsonObject("data").has("target_location")));
assertEquals("hit", abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data").get("outcome").getAsString());
}
private static List<com.google.gson.JsonObject> events(Path root) throws Exception {
var values = new ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(root.resolve("events"))) {
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(file))
if (!line.isBlank()) values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return values;
}
private static List<com.google.gson.JsonObject> abilityEvents(List<com.google.gson.JsonObject> events, String suffix) {
return events.stream().filter(event -> event.get("type").getAsString().endsWith(suffix)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("chain_lightning")).toList();
}
@Test void cancelledMiddleHopContinuesWithoutClaimingItsNativeDamage() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var caster = new FantasyStickLifecycleNativeTest.Actor(data); prepareCaster(data, caster, true);
fixture.players.put(caster.core.id, caster.player); fixture.activate(caster);
var first = mock(LivingEntity.class); var cancelled = mock(LivingEntity.class); var third = mock(LivingEntity.class);
locate(first, caster.world, UUID.randomUUID(), 10);
locate(cancelled, caster.world, UUID.randomUUID(), 14);
locate(third, caster.world, UUID.randomUUID(), 18);
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getLocation();
doReturn(new Location(caster.world, 0, 65, 0)).when(caster.player).getEyeLocation();
doReturn(true).when(caster.player).hasLineOfSight(first);
doReturn(new org.bukkit.util.RayTraceResult(new org.bukkit.util.Vector(10, 64, 0), first))
.when(caster.world).rayTraceEntities(any(Location.class), any(org.bukkit.util.Vector.class), eq(16.0),
org.mockito.ArgumentMatchers.<java.util.function.Predicate<org.bukkit.entity.Entity>>any());
doReturn(List.of(cancelled)).when(first).getNearbyEntities(anyDouble(), anyDouble(), anyDouble());
doReturn(List.of(third)).when(cancelled).getNearbyEntities(anyDouble(), anyDouble(), anyDouble());
doReturn(true).when(first).hasLineOfSight(cancelled); doReturn(true).when(cancelled).hasLineOfSight(third);
var applied = new LinkedHashMap<UUID, Double>();
for (var target : List.of(first, cancelled, third)) nativeDamage(fixture, target, applied);
class Veto implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.LOWEST)
public void cancel(EntityDamageEvent event) { if (event.getEntity() == cancelled) event.setCancelled(true); }
}
fixture.dispatcher.register(new Veto(), fixture.plugin);
use(fixture, caster); drain(fixture, 2);
assertEquals(2, applied.size()); assertFalse(applied.containsKey(cancelled.getUniqueId()));
}
var records = events(data); var hits = abilityEvents(records, "combat.damage");
assertEquals(List.of(1, 3), hits.stream().map(event -> event.getAsJsonObject("data").get("hop_ordinal").getAsInt()).toList());
assertEquals("7.3125", hits.getLast().getAsJsonObject("data").get("defined_amount").getAsString());
assertEquals(3, abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data").get("attempted_hops").getAsInt());
assertEquals(2, abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data").get("accepted_hops").getAsInt());
}
@SuppressWarnings("unchecked")
@@ -221,6 +284,18 @@ class ChainLightningNativeTest {
assertEquals(0, new BigDecimal("7").compareTo(configured.parameters().get("jump-range")));
assertEquals(new BigDecimal("1"), configured.parameters().get("additional-targets"));
assertEquals(new BigDecimal("0.25"), configured.parameters().get("jump-multiplier"));
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());
fixture.command.execute(sender.port, "fstickadmin",
new String[]{"config", "chain-lightning-jump-range-blocks", "8"});
var failed = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(failed); failed.run();
}
assertEquals("7", ((games.dmg.fantasystick.persistence.ChainLightningSettings)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "chainLightningSettings"))
.value("chain-lightning-jump-range-blocks"));
assertTrue(sender.messages.getLast().contains("save failed"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "0"});
assertTrue(sender.messages.getLast().contains("invalid"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "7", "extra"});
@@ -232,6 +307,11 @@ class ChainLightningNativeTest {
assertEquals(new BigDecimal("0.25"), saved.parameters().get("jump-multiplier"));
assertEquals(java.time.Duration.ofSeconds(3), saved.cooldown());
}
var config = events(data).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().startsWith("chain-lightning-")).toList();
assertEquals(7, config.size(), "One authorized read plus six acknowledged saved settings, no denied/invalid success");
}
private static void nativeDamage(FantasyStickLifecycleNativeTest.Fixture fixture, LivingEntity target, Map<UUID, Double> damage) {
@@ -22,7 +22,7 @@ class CharacterBookNativeTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookEventSinkFailureCannotChangeTheAlreadyAuthorizedNativeBookPath() {
var player=org.mockito.Mockito.mock(org.bukkit.entity.Player.class);var world=org.mockito.Mockito.mock(org.bukkit.World.class);var record=org.mockito.Mockito.mock(games.dmg.fantasystick.persistence.CharacterRecord.class);var playerId=UUID.randomUUID();var active=new games.dmg.fantasystick.session.EquipmentSessions.Active(playerId,UUID.randomUUID(),UUID.randomUUID());org.mockito.Mockito.when(player.getUniqueId()).thenReturn(playerId);org.mockito.Mockito.when(player.getName()).thenReturn("Player");org.mockito.Mockito.when(world.getUID()).thenReturn(UUID.randomUUID());org.mockito.Mockito.when(world.getName()).thenReturn("world");org.mockito.Mockito.when(player.getLocation()).thenReturn(new org.bukkit.Location(world,1,2,3));org.mockito.Mockito.when(record.name()).thenReturn("Hero");org.mockito.Mockito.when(record.revision()).thenReturn(java.math.BigInteger.ONE);var input=new games.dmg.fantasystick.session.ControlInput(player,active,0,"",games.dmg.fantasystick.session.ControlInput.Kind.BOOK,games.dmg.fantasystick.session.ControlInput.Gesture.USE,null,0,0,()->true);var bridge=new games.dmg.fantasystick.events.BookEventBridge(event->{throw new IllegalStateException("disk");},java.time.Clock.systemUTC());assertDoesNotThrow(()->{var request=bridge.request(input,record,0);bridge.opened(request,record,3,1);bridge.refused(request,"retry");});
var player=org.mockito.Mockito.mock(org.bukkit.entity.Player.class);var world=org.mockito.Mockito.mock(org.bukkit.World.class);var record=org.mockito.Mockito.mock(games.dmg.fantasystick.persistence.CharacterRecord.class);var playerId=UUID.randomUUID();var active=new games.dmg.fantasystick.session.EquipmentSessions.Active(playerId,UUID.randomUUID(),UUID.randomUUID());org.mockito.Mockito.when(player.getUniqueId()).thenReturn(playerId);org.mockito.Mockito.when(player.getName()).thenReturn("Player");org.mockito.Mockito.when(world.getUID()).thenReturn(UUID.randomUUID());org.mockito.Mockito.when(world.getName()).thenReturn("world");org.mockito.Mockito.when(player.getLocation()).thenReturn(new org.bukkit.Location(world,1,2,3));org.mockito.Mockito.when(record.name()).thenReturn("Hero");org.mockito.Mockito.when(record.revision()).thenReturn(java.math.BigInteger.ONE);org.mockito.Mockito.when(record.stats()).thenReturn(java.util.Map.of(games.dmg.fantasystick.progression.Stat.CON,java.math.BigInteger.ZERO));org.mockito.Mockito.when(record.unspentPoints()).thenReturn(java.math.BigInteger.ONE);org.mockito.Mockito.when(record.maximumHealth()).thenReturn(java.math.BigDecimal.TEN);org.mockito.Mockito.when(record.health()).thenReturn(java.math.BigDecimal.ONE);org.mockito.Mockito.when(record.cooldowns()).thenReturn(java.util.Map.of());org.mockito.Mockito.when(record.loadout()).thenReturn(java.util.List.of("","","","","","",""));var input=new games.dmg.fantasystick.session.ControlInput(player,active,0,"",games.dmg.fantasystick.session.ControlInput.Kind.BOOK,games.dmg.fantasystick.session.ControlInput.Gesture.USE,null,0,0,()->true);var bridge=new games.dmg.fantasystick.events.BookEventBridge(event->{throw new IllegalStateException("disk");},java.time.Clock.systemUTC());var progression=new games.dmg.fantasystick.events.ProgressionEventBridge(event->{throw new IllegalStateException("disk");},java.time.Clock.systemUTC());var abilityEvents=new games.dmg.fantasystick.events.AbilityEventBridge(event->{throw new IllegalStateException("disk");},java.time.Clock.systemUTC());var passiveEvents=new games.dmg.fantasystick.events.PassiveEventBridge(event->{throw new IllegalStateException("disk");},java.time.Clock.systemUTC());assertDoesNotThrow(()->{var request=bridge.request(input,record,0);bridge.opened(request,record,3,1);bridge.refused(request,"retry");var action=progression.begin(input,record,games.dmg.fantasystick.progression.Stat.CON,java.math.BigDecimal.ONE,java.math.BigDecimal.TEN,java.math.BigDecimal.valueOf(1024));progression.increased(action,record,"acknowledged",java.math.BigDecimal.ONE);var unlearn=abilityEvents.begin(input,record,"fireball");abilityEvents.failed(unlearn,"save_failed");var definition=new games.dmg.fantasystick.passive.QuickerRegenerationI(games.dmg.fantasystick.passive.QuickerRegenerationI.Settings.defaults()).definition();var passive=passiveEvents.begin(input,record,definition,true);passiveEvents.failed(passive,"save_failed");});
}
@Test void registeredOpenReachesOneAcknowledgedFeatureEventAfterAuthoritativeReadAndNativeOpen() throws Exception {
@@ -112,7 +112,17 @@ class CharacterBookNativeTest {
assertEquals(saved, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow(),
"A rendered purchase action is single-use");
}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.opened")).count(),"The automatic post-purchase refresh must not duplicate a user-requested book-open outcome");
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.opened")).count(),"The automatic post-purchase refresh must not duplicate a user-requested book-open outcome");var increased=events.stream().filter(event->event.get("type").getAsString().endsWith("character.stat_increased")).toList();assertEquals(1,increased.size());var value=increased.getFirst().getAsJsonObject("data");assertEquals("CON",value.get("stat_id").getAsString());assertEquals("0",value.get("stat_before").getAsString());assertEquals("1",value.get("stat_after").getAsString());assertEquals("40",value.get("points_before").getAsString());assertEquals("39",value.get("points_after").getAsString());assertEquals("5.0",value.get("health_before").getAsString());assertEquals("6.0",value.get("health_after").getAsString());assertEquals("10.0",value.get("maximum_health_before").getAsString());assertEquals("11",value.get("maximum_health_after").getAsString());assertEquals("acknowledged",value.get("native_publication").getAsString());var action=value.get("action_id").getAsString();assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("action.observed")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("character_stat_increase")&&event.getAsJsonObject("data").get("action_id").getAsString().equals(action)).count());assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("character_stat_increase")&&event.getAsJsonObject("data").get("action_id").getAsString().equals(action)).count());
}
@Test void everyPermanentStatPublishesExactlyOneAcknowledgedMutation() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);f.activate(a);PartyBookNativeTest.open(f,a);for(var stat:games.dmg.fantasystick.progression.Stat.values()){PartyBookNativeTest.click(f,a,PartyBookNativeTest.button(a,"[+1 "+stat+"]"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}f.plugin.setEnabled(false);f.awaitWorkers();}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var increased=events.stream().filter(event->event.get("type").getAsString().endsWith("character.stat_increased")).toList();assertEquals(6,increased.size());assertEquals(java.util.Arrays.stream(games.dmg.fantasystick.progression.Stat.values()).map(Enum::name).collect(java.util.stream.Collectors.toSet()),increased.stream().map(event->event.getAsJsonObject("data").get("stat_id").getAsString()).collect(java.util.stream.Collectors.toSet()));assertEquals(6,increased.stream().map(event->event.get("id").getAsString()).distinct().count());assertTrue(increased.stream().allMatch(event->event.getAsJsonObject("data").get("native_publication").getAsString().equals("acknowledged")));
}
@Test void conPurchaseAtTheNativeCeilingRecordsStatAndPointChangeWithoutInventingHealing() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var old=a.core.record;var stats=new java.util.EnumMap<games.dmg.fantasystick.progression.Stat,java.math.BigInteger>(games.dmg.fantasystick.progression.Stat.class);stats.putAll(old.stats());stats.put(games.dmg.fantasystick.progression.Stat.CON,java.math.BigInteger.valueOf(1014));var prepared=new games.dmg.fantasystick.persistence.CharacterRecord(old.id(),old.name(),old.revision().add(java.math.BigInteger.ONE),stats,old.level(),old.experience(),java.math.BigInteger.ONE,java.math.BigDecimal.TEN,old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,old.deathId(),old.diedAt(),old.coins(),old.coinsInitialized(),old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(prepared,old.revision());f.players.put(a.core.id,a.player);f.activate(a);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,PartyBookNativeTest.button(a,"[+1 CON]"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);f.plugin.setEnabled(false);f.awaitWorkers();}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var value=events.stream().filter(event->event.get("type").getAsString().endsWith("character.stat_increased")).map(event->event.getAsJsonObject("data")).findFirst().orElseThrow();assertEquals("1014",value.get("stat_before").getAsString());assertEquals("1015",value.get("stat_after").getAsString());assertEquals(0,new java.math.BigDecimal(value.get("maximum_health_before").getAsString()).compareTo(new java.math.BigDecimal(value.get("maximum_health_after").getAsString())));assertEquals(0,new java.math.BigDecimal(value.get("health_before").getAsString()).compareTo(new java.math.BigDecimal(value.get("health_after").getAsString())));
}
@Test void conPurchaseKeepsItsOwnMaximumAndOneTimeGainDespiteExternalModifiers() throws Exception {
@@ -185,6 +195,7 @@ class CharacterBookNativeTest {
assertTrue(PartyBookNativeTest.text(b).contains("STR: 1"));
assertEquals(saved.stats(), characters(f).find(saved.id()).get(5, TimeUnit.SECONDS).orElseThrow().stats());
}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var statEvents=events.stream().filter(event->event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("character_stat_increase")).toList();assertEquals(1,statEvents.stream().filter(event->event.get("type").getAsString().endsWith("character.stat_increased")&&event.getAsJsonObject("data").get("stat_id").getAsString().equals("STR")).count());assertTrue(statEvents.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")).map(event->event.getAsJsonObject("data")).anyMatch(value->value.get("outcome").getAsString().equals("failed")&&value.get("reason").getAsString().equals("save_failed")));assertTrue(statEvents.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")).map(event->event.getAsJsonObject("data")).anyMatch(value->value.get("outcome").getAsString().equals("refused")&&value.get("reason").getAsString().equals("stale_forged_or_repeated_action")));
}
@Test void nativePagesKeepLargeNumbersUnicodeNamesAndUnknownAbilitiesReadableAndComplete() throws Exception {
@@ -42,11 +42,12 @@ class CombatExperienceNativeTest {
@Test void tooCloseHostileDeathDiscardsItsAllocatedCoinsAndPublishesNoCoinFeedback() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"hostile-coin-drop-min: 10\nhostile-coin-drop-max: 10\n");
UUID recipient,worldId;
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 world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
f.players.put(actor.core.id, actor.player); f.activate(actor);recipient=actor.core.record.id();
var world = mock(org.bukkit.World.class); worldId=UUID.randomUUID();when(world.getUID()).thenReturn(worldId);
var first = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
hit(f, actor.player, first, false); die(f, first); settle(f);
var eligible = record(f, actor);
@@ -64,6 +65,7 @@ class CombatExperienceNativeTest {
assertEquals(new java.math.BigDecimal("1.0"), blocked.restrictedKills().values().iterator().next().x(),
"Blocked attempts still advance the spacing reference");
}
var records=events(data).stream().filter(event->event.get("type").getAsString().endsWith("combat.reward_resolved")).toList();assertEquals(2,records.size());var allowed=records.get(0).getAsJsonObject("data");var blocked=records.get(1).getAsJsonObject("data");assertEquals(recipient.toString(),allowed.get("character_id").getAsString());assertEquals("ordinary_spacing_eligible",allowed.get("resolution").getAsString());assertEquals("10",allowed.get("allocated_coins").getAsString());assertEquals("10",allowed.get("applied_coins").getAsString());assertEquals("10",allowed.get("experience_pool").getAsString());assertEquals("1",allowed.get("reward_unit_count").getAsString());assertEquals(java.util.List.of(recipient.toString()),allowed.getAsJsonArray("direct_contributor_character_ids").asList().stream().map(com.google.gson.JsonElement::getAsString).toList());assertTrue(allowed.has("before_points"));assertTrue(allowed.has("after_points"));assertEquals("ordinary_spacing_blocked",blocked.get("resolution").getAsString());assertEquals("10",blocked.get("allocated_coins").getAsString());assertEquals("0",blocked.get("applied_coins").getAsString());assertEquals("spacing_refusal",blocked.get("feedback").getAsString());assertEquals(worldId.toString(),blocked.getAsJsonObject("ordinary_history_after").get("world_id").getAsString());assertEquals("1.0",blocked.getAsJsonObject("ordinary_history_after").get("x").getAsString());
}
@Test void everyManagedEncounterOriginWaivesDistanceWithoutChangingRestrictedHistory() throws Exception {
@@ -74,13 +76,16 @@ class CombatExperienceNativeTest {
var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);f.activate(actor);combat.attachManagedOrigins(entity->java.util.Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(kind,UUID.nameUUIDFromBytes(entity.getUniqueId().toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)))));var world=mock(org.bukkit.World.class);when(world.getUID()).thenReturn(UUID.randomUUID());var before=record(f,actor);var first=mob(EntityType.ZOMBIE,new Location(world,0,64,0));hit(f,actor.player,first,false);die(f,first);settle(f);var second=mob(EntityType.ZOMBIE,new Location(world,1,64,0));hit(f,actor.player,second,false);die(f,second);settle(f);var saved=record(f,actor);assertTrue(saved.experience().compareTo(before.experience())>0,kind.toString());assertEquals(before.coins().add(java.math.BigInteger.valueOf(20)),saved.coins(),kind.toString());assertEquals(before.restrictedKills(),saved.restrictedKills(),kind+" never consults or advances ordinary spacing history");
}
}
var records=events(data).stream().filter(event->event.get("type").getAsString().endsWith("combat.reward_resolved")).map(event->event.getAsJsonObject("data")).toList();assertEquals(6,records.size());for(var kind:games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.values()){var matching=records.stream().filter(event->event.get("managed_origin_kind").getAsString().equals(kind.name().toLowerCase(java.util.Locale.ROOT))).toList();assertEquals(2,matching.size(),kind.toString());for(var event:matching){assertEquals("managed_origin_exempt",event.get("resolution").getAsString());assertEquals("10",event.get("allocated_coins").getAsString());assertEquals("10",event.get("applied_coins").getAsString());assertFalse(event.has("ordinary_history_before"));assertFalse(event.has("ordinary_history_after"));assertTrue(event.has("managed_origin_source"));}}
}
@Test void repeatedManagedKillAuthorityCannotPayTwice() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"hostile-coin-drop-min: 10\nhostile-coin-drop-max: 10\n");
UUID authority=UUID.randomUUID();
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var combat=(games.dmg.fantasystick.party.CombatExperience)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"combatExperience");var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);f.activate(actor);var authority=UUID.randomUUID();combat.attachManagedOrigins(entity->java.util.Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,authority)));var world=mock(org.bukkit.World.class);when(world.getUID()).thenReturn(UUID.randomUUID());var before=record(f,actor);for(int attempt=0;attempt<2;attempt++){var target=mob(EntityType.ZOMBIE,new Location(world,0,64,0));hit(f,actor.player,target,false);die(f,target);settle(f);}var saved=record(f,actor);assertEquals(before.experience().add(java.math.BigInteger.TEN),saved.experience());assertEquals(before.coins().add(java.math.BigInteger.TEN),saved.coins());assertEquals(before.restrictedKills(),saved.restrictedKills());
f.plugin.setEnabled(true);var combat=(games.dmg.fantasystick.party.CombatExperience)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"combatExperience");var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);f.activate(actor);combat.attachManagedOrigins(entity->java.util.Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,authority)));var world=mock(org.bukkit.World.class);when(world.getUID()).thenReturn(UUID.randomUUID());var before=record(f,actor);for(int attempt=0;attempt<2;attempt++){var target=mob(EntityType.ZOMBIE,new Location(world,0,64,0));hit(f,actor.player,target,false);die(f,target);settle(f);}var saved=record(f,actor);assertEquals(before.experience().add(java.math.BigInteger.TEN),saved.experience());assertEquals(before.coins().add(java.math.BigInteger.TEN),saved.coins());assertEquals(before.restrictedKills(),saved.restrictedKills());
}
var records=events(data).stream().filter(event->event.get("type").getAsString().endsWith("combat.reward_resolved")&&event.getAsJsonObject("data").get("managed_origin_source").getAsString().equals(authority.toString())).toList();assertEquals(2,records.size());assertEquals(1,records.stream().map(event->event.get("id").getAsString()).distinct().count());assertEquals(java.util.Set.of("applied","duplicate"),records.stream().map(event->event.getAsJsonObject("data").get("storage_status").getAsString()).collect(java.util.stream.Collectors.toSet()));
}
@Test void everyRestrictedMobCoinClassUsesTheExistingExactAndCrossWorldSpacingGeometry() throws Exception {
@@ -132,11 +137,12 @@ class CombatExperienceNativeTest {
@Test void partyRecipientsApplyIndependentSpacingWithoutRedistributingBlockedShares() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"hostile-coin-drop-min: 20\nhostile-coin-drop-max: 20\n");
UUID characterA,characterB,sharedTarget;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a); f.activate(b);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a); f.activate(b);characterA=a.core.record.id();characterB=b.core.record.id();
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
var history = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
hit(f, a.player, history, false); die(f, history); settle(f);
@@ -144,7 +150,7 @@ class CombatExperienceNativeTest {
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 shared = mob(EntityType.ZOMBIE, new Location(world, 1, 64, 0));
var shared = mob(EntityType.ZOMBIE, new Location(world, 1, 64, 0));sharedTarget=shared.getUniqueId();
hit(f, a.player, shared, false); die(f, shared); settle(f); settle(f);
var afterA = record(f, a); var afterB = record(f, b);
assertEquals(beforeA.experience(), afterA.experience());
@@ -156,6 +162,7 @@ class CombatExperienceNativeTest {
assertTrue(a.messages.stream().anyMatch(message -> message.contains("too close")));
assertTrue(b.messages.stream().anyMatch(message -> message.contains("10 character coins")));
}
var records=events(data).stream().filter(event->event.get("type").getAsString().endsWith("combat.reward_resolved")&&event.getAsJsonObject("data").get("target_id").getAsString().equals(sharedTarget.toString())).toList();assertEquals(2,records.size());var byCharacter=records.stream().collect(java.util.stream.Collectors.toMap(event->event.getAsJsonObject("data").get("recipient_character_id").getAsString(),event->event.getAsJsonObject("data")));assertEquals("ordinary_spacing_blocked",byCharacter.get(characterA.toString()).get("resolution").getAsString());assertEquals("0",byCharacter.get(characterA.toString()).get("applied_coins").getAsString());assertEquals("ordinary_spacing_eligible",byCharacter.get(characterB.toString()).get("resolution").getAsString());assertEquals("10",byCharacter.get(characterB.toString()).get("applied_coins").getAsString());assertEquals(2,records.stream().map(event->event.get("id").getAsString()).distinct().count());
}
@Test void packagedWitchPoolFlowsThroughExactSoloAndPartyAllocationWithOrdinaryCoins() throws Exception {
@@ -365,28 +372,28 @@ class CombatExperienceNativeTest {
var writes = new java.util.concurrent.atomic.AtomicInteger();
when(storage.awardCombatReward(eq(original.id()), any(), any(), eq(java.math.BigInteger.valueOf(20)), any(),
same(progression), any(), any())).thenAnswer(ignored -> writes.getAndIncrement() == 0 ? failed : success);
var failures = new ArrayList<games.dmg.fantasystick.party.CombatExperience.Failure>();
var failures = new ArrayList<games.dmg.fantasystick.party.CombatExperience.Failure>();var emitted=new ArrayList<games.dmg.fantasystick.events.EventInput>();var clock=java.time.Clock.fixed(java.time.Instant.parse("2026-09-19T20:00:00Z"), java.time.ZoneOffset.UTC);
var service = new games.dmg.fantasystick.party.CombatExperience(storage, progression,
games.dmg.fantasystick.progression.CombatRewardRules.defaults(), sessions,
java.time.Clock.fixed(java.time.Instant.parse("2026-09-19T20:00:00Z"), java.time.ZoneOffset.UTC), Runnable::run,
clock, Runnable::run,
ignored -> attacker, failures::add);
var authority=new games.dmg.fantasystick.progression.ManagedEncounterOrigin(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,UUID.randomUUID());var settled=new ArrayList<games.dmg.fantasystick.progression.ManagedEncounterOrigin>();service.attachManagedOrigins(ignored->java.util.Optional.of(authority));service.attachManagedSettlement(settled::add);
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
var authority=new games.dmg.fantasystick.progression.ManagedEncounterOrigin(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,UUID.randomUUID());var settled=new ArrayList<games.dmg.fantasystick.progression.ManagedEncounterOrigin>();service.attachManagedOrigins(ignored->java.util.Optional.of(authority));service.attachManagedSettlement(settled::add);service.attachEvents(new games.dmg.fantasystick.events.CombatRewardEventBridge(emitted::add,clock));
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());when(world.getName()).thenReturn("world");when(attacker.getName()).thenReturn("Attacker");when(attacker.getLocation()).thenReturn(new Location(world,0,64,0));
var target = mob(EntityType.WITCH, new Location(world, 0, 64, 0));
var damageSource = mock(org.bukkit.damage.DamageSource.class);
when(damageSource.getDirectEntity()).thenReturn(attacker); when(damageSource.getCausingEntity()).thenReturn(attacker);
service.damage(new EntityDamageByEntityEvent(attacker, target, DamageCause.ENTITY_ATTACK, damageSource, 1));
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
verify(storage, times(1)).awardCombatReward(eq(original.id()), any(), any(), any(), any(), any(), any(), any());
assertEquals(1, failures.size());assertTrue(settled.isEmpty(),"slot authority remains unsettled while reward persistence is uncertain");verify(attacker, never()).sendMessage(anyString());
assertEquals(1, failures.size());assertTrue(settled.isEmpty(),"slot authority remains unsettled while reward persistence is uncertain");assertTrue(emitted.isEmpty(),"Failed persistence cannot claim a reward resolution");verify(attacker, never()).sendMessage(anyString());
service.configure(service.rules().withHardPool(java.math.BigInteger.valueOf(25)));
service.retryPending();
verify(storage, times(2)).awardCombatReward(eq(original.id()), any(), any(), any(), any(), any(), any(), any());
verify(attacker, times(1)).sendMessage(contains("character coins"));assertEquals(java.util.List.of(authority),settled,"slot defeat may publish only after the captured payout is durable");
verify(attacker, times(1)).sendMessage(contains("character coins"));assertEquals(java.util.List.of(authority),settled,"slot defeat may publish only after the captured payout is durable");assertEquals(1,emitted.size());var evidence=emitted.getFirst();assertEquals("20",evidence.data().get("experience_pool"));assertEquals("managed_origin_exempt",evidence.data().get("resolution"));assertEquals(authority.source().toString(),evidence.data().get("managed_origin_source"));assertEquals(java.util.List.of(original.id().toString()),evidence.data().get("direct_contributor_character_ids"));
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
service.retryPending();
verify(storage, times(2)).awardCombatReward(eq(original.id()), any(), any(), any(), any(), any(), any(), any());
verify(attacker, times(1)).sendMessage(contains("character coins"));
verify(attacker, times(1)).sendMessage(contains("character coins"));assertEquals(1,emitted.size(),"Duplicate target callbacks cannot mint another reward record");
service.close();
}
@@ -423,6 +430,7 @@ class CombatExperienceNativeTest {
assertTrue(a.messages.stream().anyMatch(message -> message.contains("Level " + expected)));
}
}
var coinConfig=events(data).stream().filter(event->event.get("type").getAsString().endsWith("config.read")||event.get("type").getAsString().endsWith("config.changed")).filter(event->event.getAsJsonObject("data").get("key").getAsString().equals("starting-coins-min")).toList();assertEquals(2,coinConfig.size());assertTrue(coinConfig.stream().anyMatch(event->event.get("type").getAsString().endsWith("config.read")&&event.getAsJsonObject("data").get("after").getAsString().equals("1")));assertTrue(coinConfig.stream().anyMatch(event->event.get("type").getAsString().endsWith("config.changed")&&event.getAsJsonObject("data").get("before").getAsString().equals("1")&&event.getAsJsonObject("data").get("after").getAsString().equals("0")));
}
@Test void hardMobXpCommandPersistsBeforePublishingAndAffectsOnlyFutureWitchDefeats() throws Exception {
@@ -475,20 +483,20 @@ class CombatExperienceNativeTest {
assertEquals(java.math.BigInteger.ZERO, record(f, a).experience(), "Cancelled damage never contributes");
}
var second = data.resolve("accepted"); java.nio.file.Files.createDirectories(second);
var second = data.resolve("accepted"); java.nio.file.Files.createDirectories(second);UUID finalAttacker,finalVictimPlayer,deathEpisode;java.math.BigInteger finalVictimCoins,finalAttackerCoins;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(second)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(second);
var b = new FantasyStickLifecycleNativeTest.Actor(second);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
f.activate(a); f.activate(b);
var attackerCoins = record(f, a).coins();
var victimCoins = record(f, b).coins();
f.activate(a); f.activate(b);finalAttacker=a.core.record.id();finalVictimPlayer=b.core.id;
var attackerCoins = record(f, a).coins();finalAttackerCoins=attackerCoins;
var victimCoins = record(f, b).coins();finalVictimCoins=victimCoins;
hit(f, a.player, b.player, false);
var source = mock(org.bukkit.damage.DamageSource.class);
var defeat = new org.bukkit.event.entity.PlayerDeathEvent(b.player, source, new ArrayList<>(), 0,
net.kyori.adventure.text.Component.text("defeat"), true);
f.dispatcher.accept(defeat);settle(f);var downed=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&downed.all().isEmpty();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var captured=downed.all().getFirst().attribution();assertTrue(captured.contributors().contains(a.core.record.id()),"Originating character attribution survives the wait");assertEquals(java.math.BigInteger.ZERO,record(f,a).experience(),"Downing alone grants no defeat XP");assertEquals(victimCoins,record(f,b).coins(),"Downing alone loses no victim coins");DownedSessionNativeTest.acceptDeath(f,b);settle(f);settle(f);settle(f);for(int attempt=0;attempt<120&&record(f,a).experience().signum()==0;attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}
f.dispatcher.accept(defeat);settle(f);var downed=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&downed.all().isEmpty();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var resolvedEpisode=downed.all().getFirst();deathEpisode=resolvedEpisode.id();var captured=resolvedEpisode.attribution();assertTrue(captured.contributors().contains(a.core.record.id()),"Originating character attribution survives the wait");assertEquals(java.math.BigInteger.ZERO,record(f,a).experience(),"Downing alone grants no defeat XP");assertEquals(victimCoins,record(f,b).coins(),"Downing alone loses no victim coins");DownedSessionNativeTest.acceptDeath(f,b);settle(f);settle(f);settle(f);for(int attempt=0;attempt<120&&record(f,a).experience().signum()==0;attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}
assertTrue(defeat.isCancelled());
assertEquals(java.math.BigInteger.TEN,record(f,a).experience(),"Level-one fantasy defeat supplies a ten-XP pool");
assertEquals(attackerCoins.add(victimCoins), record(f, a).coins());
@@ -497,6 +505,7 @@ class CombatExperienceNativeTest {
f.dispatcher.accept(defeat); settle(f);
assertEquals(java.math.BigInteger.TEN, record(f, a).experience(), "Repeated intercepted callback cannot award twice");
}
var rewards=events(second).stream().filter(event->event.get("type").getAsString().endsWith("combat.reward_resolved")).toList();assertEquals(1,rewards.size(),"Initial downing and repeated callbacks emit no extra reward resolution");var reward=rewards.getFirst().getAsJsonObject("data");assertEquals("final_death",reward.get("stage").getAsString());assertEquals(finalVictimPlayer.toString(),reward.get("target_id").getAsString());assertEquals("10",reward.get("experience_pool").getAsString());assertEquals("10",reward.get("allocated_experience").getAsString());assertEquals("10",reward.get("applied_experience").getAsString());assertEquals(finalVictimCoins.toString(),reward.get("coin_pool").getAsString());assertEquals(finalVictimCoins.toString(),reward.get("allocated_coins").getAsString());assertEquals(finalVictimCoins.toString(),reward.get("applied_coins").getAsString());assertEquals(finalAttackerCoins.toString(),reward.get("balance_before").getAsString());assertEquals(finalAttackerCoins.add(finalVictimCoins).toString(),reward.get("balance_after").getAsString());assertEquals("spacing_not_applicable",reward.get("resolution").getAsString());assertEquals(java.util.List.of(finalAttacker.toString()),reward.getAsJsonArray("direct_contributor_character_ids").asList().stream().map(com.google.gson.JsonElement::getAsString).toList());assertTrue(reward.has("reward_unit_id"));var losses=events(second).stream().filter(event->event.get("type").getAsString().endsWith("character.coins_lost")).toList();assertEquals(1,losses.size());var loss=losses.getFirst().getAsJsonObject("data");assertEquals(deathEpisode.toString(),loss.get("death_id").getAsString());assertEquals(finalVictimCoins.toString(),loss.get("balance_before").getAsString());assertEquals("0",loss.get("balance_after").getAsString());assertEquals(finalVictimCoins.toString(),loss.get("death_bound_escrow").getAsString());assertEquals("player_attributed_final_death",loss.get("reason").getAsString());var returns=events(second).stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("final_death_return")).toList();assertEquals(1,returns.size());assertEquals(deathEpisode.toString(),returns.getFirst().getAsJsonObject("data").get("death_id").getAsString());assertEquals("returned",returns.getFirst().getAsJsonObject("data").get("outcome").getAsString());
}
private static EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Fixture f, org.bukkit.entity.Entity source,
@@ -524,6 +533,8 @@ class CombatExperienceNativeTest {
return storage.find(actor.core.record.id()).get().orElseThrow();
}
private static java.util.List<com.google.gson.JsonObject> events(Path data)throws Exception{var records=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}return records;}
private static LivingEntity type(EntityType type) {
var entity = mock(LivingEntity.class); when(entity.getType()).thenReturn(type); return entity;
}
@@ -45,7 +45,7 @@ class DownedSessionNativeTest{
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);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);actor.core.location.add(7,0,3);var anchor=actor.core.location.clone();actor.core.health=0;var death=death(actor);f.dispatcher.accept(death);actor.player.setHealth(death.getReviveHealth());var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&(directory.all().isEmpty()||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(death.isCancelled());assertEquals(1d,actor.player.getHealth());var episode=directory.all().getFirst();assertEquals(actor.core.record.id(),episode.character());assertEquals(actor.core.id,episode.player());assertEquals(anchor.getWorld().getUID(),episode.world());assertEquals(anchor.getX(),episode.x());assertEquals(games.dmg.fantasystick.session.DownedEpisode.Phase.PENDING,episode.phase());var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(characters.find(actor.core.record.id()).join().orElseThrow().dead());assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");var active=sessions.active(actor.core.id).orElseThrow();assertFalse(sessions.current(active));assertTrue(sessions.downed(actor.core.id).isPresent());assertEquals(org.bukkit.Material.GRAY_DYE,actor.inventories.getLast().getItem(13).getType());}
}
@Test void claimedReviveRestoresFullHealthInPlaceAndAddsBoundedDamageProtection()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);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);var retainedPhoenix=(games.dmg.fantasystick.shop.RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");retainedPhoenix.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();actor.core.location.add(4,0,2);var anchor=actor.core.location.clone();actor.core.health=0;f.dispatcher.accept(death(actor));var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");var outcome=new java.util.concurrent.atomic.AtomicReference<games.dmg.fantasystick.session.DownedController.RescueStatus>();for(int attempt=0;attempt<80&&!controller.downed(actor.player);attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}controller.revive(actor.player,java.time.Duration.ofSeconds(5),outcome::set);for(int attempt=0;attempt<80&&outcome.get()==null;attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertEquals(games.dmg.fantasystick.session.DownedController.RescueStatus.APPLIED,outcome.get());assertEquals(actor.maximumHealth.getValue(),actor.player.getHealth());assertEquals(anchor,actor.player.getLocation());var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");assertTrue(sessions.current(sessions.active(actor.core.id).orElseThrow()));assertTrue(sessions.downedProtected(actor.core.id));assertFalse(new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead());var damage=org.mockito.Mockito.mock(org.bukkit.event.entity.EntityDamageEvent.class);org.mockito.Mockito.when(damage.getEntity()).thenReturn(actor.player);controller.damage(damage);org.mockito.Mockito.verify(damage).setCancelled(true);assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());sessions.exit(actor.player);f.awaitCharacterAndReceipt(actor);sessions.arrive(actor.player);for(int attempt=0;attempt<120&&sessions.active(actor.core.id).isEmpty();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(sessions.downedProtected(actor.core.id),"Reconnect retains the original absolute protection deadline");f.plugin.setEnabled(false);var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertTrue(events.stream().filter(event->event.get("type").getAsString().endsWith("combat.prevented")).map(event->event.getAsJsonObject("data")).anyMatch(event->event.get("reason").getAsString().equals("revive_protection")));assertEquals(java.math.BigInteger.ONE,retainedPhoenix.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN));}
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);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);var retainedPhoenix=(games.dmg.fantasystick.shop.RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");retainedPhoenix.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();actor.core.location.add(4,0,2);var anchor=actor.core.location.clone();actor.core.health=0;f.dispatcher.accept(death(actor));var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");var outcome=new java.util.concurrent.atomic.AtomicReference<games.dmg.fantasystick.session.DownedController.RescueStatus>();for(int attempt=0;attempt<80&&!controller.downed(actor.player);attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}controller.revive(actor.player,java.time.Duration.ofSeconds(5),outcome::set);for(int attempt=0;attempt<80&&outcome.get()==null;attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertEquals(games.dmg.fantasystick.session.DownedController.RescueStatus.APPLIED,outcome.get());assertEquals(actor.maximumHealth.getValue(),actor.player.getHealth());assertEquals(anchor,actor.player.getLocation());var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");assertTrue(sessions.current(sessions.active(actor.core.id).orElseThrow()));assertTrue(sessions.downedProtected(actor.core.id));assertFalse(new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead());var damage=org.mockito.Mockito.mock(org.bukkit.event.entity.EntityDamageEvent.class);org.mockito.Mockito.when(damage.getEntity()).thenReturn(actor.player);controller.damage(damage);org.mockito.Mockito.verify(damage).setCancelled(true);assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());sessions.exit(actor.player);f.awaitCharacterAndReceipt(actor);sessions.arrive(actor.player);for(int attempt=0;attempt<120&&sessions.active(actor.core.id).isEmpty();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(sessions.downedProtected(actor.core.id),"Reconnect retains the original absolute protection deadline");f.plugin.setEnabled(false);var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertTrue(events.stream().filter(event->event.get("type").getAsString().endsWith("combat.prevented")).map(event->event.getAsJsonObject("data")).anyMatch(event->event.get("reason").getAsString().equals("revive_protection")));assertEquals(java.math.BigInteger.ONE,retainedPhoenix.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN));assertTrue(events.stream().noneMatch(event->event.get("type").getAsString().endsWith("character.died")),"In-place rescue has no final death");}
}
@Test void downedFreezeCancelsMotionCombatHealingHungerItemsAndInventoryMutation()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);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);actor.core.health=0;f.dispatcher.accept(death(actor));var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");for(int attempt=0;attempt<80&&(!controller.downed(actor.player)||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}org.mockito.Mockito.verify(actor.player).setGravity(false);var from=actor.core.location.clone();var to=from.clone().add(1,0,0);var move=org.mockito.Mockito.mock(org.bukkit.event.player.PlayerMoveEvent.class);org.mockito.Mockito.when(move.getPlayer()).thenReturn(actor.player);org.mockito.Mockito.when(move.getFrom()).thenReturn(from);org.mockito.Mockito.when(move.getTo()).thenReturn(to);controller.move(move);org.mockito.Mockito.verify(move).setTo(from);var velocity=org.mockito.Mockito.mock(org.bukkit.event.player.PlayerVelocityEvent.class);org.mockito.Mockito.when(velocity.getPlayer()).thenReturn(actor.player);controller.velocity(velocity);org.mockito.Mockito.verify(velocity).setCancelled(true);var attack=org.mockito.Mockito.mock(org.bukkit.event.entity.EntityDamageByEntityEvent.class);org.mockito.Mockito.when(attack.getDamager()).thenReturn(actor.player);controller.attack(attack);org.mockito.Mockito.verify(attack).setCancelled(true);var heal=org.mockito.Mockito.mock(org.bukkit.event.entity.EntityRegainHealthEvent.class);org.mockito.Mockito.when(heal.getEntity()).thenReturn(actor.player);controller.heal(heal);org.mockito.Mockito.verify(heal).setCancelled(true);var hunger=org.mockito.Mockito.mock(org.bukkit.event.entity.FoodLevelChangeEvent.class);org.mockito.Mockito.when(hunger.getEntity()).thenReturn(actor.player);controller.hunger(hunger);org.mockito.Mockito.verify(hunger).setCancelled(true);var consume=org.mockito.Mockito.mock(org.bukkit.event.player.PlayerItemConsumeEvent.class);org.mockito.Mockito.when(consume.getPlayer()).thenReturn(actor.player);controller.consume(consume);org.mockito.Mockito.verify(consume).setCancelled(true);var old=actor.core.inventory.getItem(4).clone();actor.core.inventory.setItem(4,new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIRT));var change=new io.papermc.paper.event.player.PlayerInventorySlotChangeEvent(actor.player,4,old,actor.core.inventory.getItem(4));((games.dmg.fantasystick.session.EquipmentSessionEvents)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessionEvents")).changed(change);assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(actor.core.id).isPresent());controller.slot(change);var task=f.tasks.poll(1,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();assertEquals(old,actor.core.inventory.getItem(4));}
@@ -57,10 +57,12 @@ class DownedSessionNativeTest{
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);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var initial=characters.find(actor.core.record.id()).join().orElseThrow();characters.saveHealth(initial.id(),initial.revision(),java.math.BigDecimal.valueOf(4),initial.maximumHealth()).join();f.activate(actor);actor.core.health=0;f.dispatcher.accept(death(actor));var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&directory.all().isEmpty();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var episode=directory.all().getFirst();var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.exit(actor.player);f.awaitCharacterAndReceipt(actor);var resolved=directory.claim(episode.id(),games.dmg.fantasystick.session.DownedEpisode.Outcome.REVIVED,UUID.randomUUID(),java.time.Duration.ofSeconds(5)).join().episode().orElseThrow();assertFalse(resolved.published());sessions.arrive(actor.player);for(int attempt=0;attempt<160&&(sessions.active(actor.core.id).isEmpty()||!directory.find(actor.core.record.id()).join().orElseThrow().published());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(sessions.current(sessions.active(actor.core.id).orElseThrow()));assertEquals(actor.maximumHealth.getValue(),actor.player.getHealth());var saved=characters.find(actor.core.record.id()).join().orElseThrow();assertEquals(0,saved.maximumHealth().compareTo(saved.health()));assertTrue(directory.find(actor.core.record.id()).join().orElseThrow().published());assertTrue(sessions.downedProtected(actor.core.id));}
}
@Test void offlineFinalizationRecordsOriginalTimeAndReconcilesTheExactSheet()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);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);actor.core.health=0;f.dispatcher.accept(death(actor));var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&directory.all().isEmpty();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var episode=directory.all().getFirst();var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.exit(actor.player);var resolved=directory.claim(episode.id(),games.dmg.fantasystick.session.DownedEpisode.Outcome.FINAL_DEATH,UUID.randomUUID(),java.time.Duration.ZERO).join().episode().orElseThrow();assertTrue(sessions.finalizeDowned(resolved));for(int attempt=0;attempt<120&&!new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var dead=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();assertEquals(resolved.resolvedAt(),dead.diedAt());assertEquals(episode.id(),dead.deathId());assertEquals(org.bukkit.Material.PAPER,actor.core.inventory.getItemInOffHand().getType());sessions.arrive(actor.player);for(int attempt=0;attempt<80&&actor.core.inventory.getItemInOffHand().getType()!=org.bukkit.Material.WRITTEN_BOOK;attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType(),actor.messages.toString());for(int attempt=0;attempt<80&&!directory.find(actor.core.record.id()).join().orElseThrow().published();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(directory.find(actor.core.record.id()).join().orElseThrow().published());}
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);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);actor.core.health=0;f.dispatcher.accept(death(actor));var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&directory.all().isEmpty();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var episode=directory.all().getFirst();var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.exit(actor.player);var resolved=directory.claim(episode.id(),games.dmg.fantasystick.session.DownedEpisode.Outcome.FINAL_DEATH,UUID.randomUUID(),java.time.Duration.ZERO).join().episode().orElseThrow();assertTrue(sessions.finalizeDowned(resolved));for(int attempt=0;attempt<120&&!new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var dead=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();assertEquals(resolved.resolvedAt(),dead.diedAt());assertEquals(episode.id(),dead.deathId());assertEquals(org.bukkit.Material.PAPER,actor.core.inventory.getItemInOffHand().getType());sessions.arrive(actor.player);for(int attempt=0;attempt<80&&actor.core.inventory.getItemInOffHand().getType()!=org.bukkit.Material.WRITTEN_BOOK;attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType(),actor.messages.toString());for(int attempt=0;attempt<80&&!directory.find(actor.core.record.id()).join().orElseThrow().published();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(directory.find(actor.core.record.id()).join().orElseThrow().published());f.plugin.setEnabled(false);var died=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank()){var event=com.google.gson.JsonParser.parseString(line).getAsJsonObject();if(event.get("type").getAsString().endsWith("character.died"))died.add(event);}}assertFalse(died.isEmpty());assertEquals(1,died.stream().map(event->event.get("id").getAsString()).distinct().count(),"At-least-once reconciliation retains one logical death ID");assertEquals(episode.id().toString(),died.getFirst().getAsJsonObject("data").get("death_id").getAsString());assertEquals(resolved.resolvedAt().toString(),died.getFirst().getAsJsonObject("data").get("effective_death_time").getAsString());}
}
@Test void deathMenuAcceptanceFinalizesTheSameEpisodeAndDeliversOneTail()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);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);var retainedPhoenix=(games.dmg.fantasystick.shop.RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");retainedPhoenix.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();actor.core.health=0;f.dispatcher.accept(death(actor));var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&(directory.all().isEmpty()||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var episode=directory.all().getFirst();var menu=actor.inventories.getLast();assertTrue(plain(menu.getItem(11)).contains("Accept Death"));assertTrue(plain(menu.getItem(13)).contains("Phoenix Down"));assertTrue(plain(menu.getItem(15)).contains("Wait for Revive"));var view=org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);org.mockito.Mockito.when(view.getTopInventory()).thenReturn(menu);var click=org.mockito.Mockito.mock(org.bukkit.event.inventory.InventoryClickEvent.class);org.mockito.Mockito.when(click.getView()).thenReturn(view);org.mockito.Mockito.when(click.getWhoClicked()).thenReturn(actor.player);org.mockito.Mockito.when(click.getRawSlot()).thenReturn(11);var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");controller.click(click);controller.click(click);for(int attempt=0;attempt<120&&!new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}PartyLifecycleNativeTest.directory(f).defeat(UUID.randomUUID()).join();f.awaitCharacterAndReceipt(actor);PartyBookNativeTest.drain(f);var dead=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();assertTrue(dead.dead());assertEquals(episode.id(),dead.deathId());assertEquals(games.dmg.fantasystick.session.DownedEpisode.Outcome.FINAL_DEATH,directory.find(dead.id()).join().orElseThrow().outcome());for(int attempt=0;attempt<80&&!directory.find(dead.id()).join().orElseThrow().published();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(directory.find(dead.id()).join().orElseThrow().published());assertEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());assertEquals(java.math.BigInteger.ONE,retainedPhoenix.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN));}
games.dmg.fantasystick.party.CharacterParty finalParty;
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);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);var persistentParties=PartyLifecycleNativeTest.directory(f);finalParty=persistentParties.create(actor.core.record.id(),"Finalists").join().party().orElseThrow();var retainedPhoenix=(games.dmg.fantasystick.shop.RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");retainedPhoenix.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();actor.core.health=0;f.dispatcher.accept(death(actor));var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&(directory.all().isEmpty()||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var episode=directory.all().getFirst();assertEquals(finalParty.id(),persistentParties.partyOf(actor.core.record.id()).orElseThrow().id());var menu=actor.inventories.getLast();assertTrue(plain(menu.getItem(11)).contains("Accept Death"));assertTrue(plain(menu.getItem(13)).contains("Phoenix Down"));assertTrue(plain(menu.getItem(15)).contains("Wait for Revive"));var view=org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);org.mockito.Mockito.when(view.getTopInventory()).thenReturn(menu);var click=org.mockito.Mockito.mock(org.bukkit.event.inventory.InventoryClickEvent.class);org.mockito.Mockito.when(click.getView()).thenReturn(view);org.mockito.Mockito.when(click.getWhoClicked()).thenReturn(actor.player);org.mockito.Mockito.when(click.getRawSlot()).thenReturn(11);var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");controller.click(click);controller.click(click);for(int attempt=0;attempt<120&&!new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}PartyLifecycleNativeTest.directory(f).defeat(UUID.randomUUID()).join();f.awaitCharacterAndReceipt(actor);PartyBookNativeTest.drain(f);var dead=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();assertTrue(dead.dead());assertEquals(episode.id(),dead.deathId());assertEquals(games.dmg.fantasystick.session.DownedEpisode.Outcome.FINAL_DEATH,directory.find(dead.id()).join().orElseThrow().outcome());for(int attempt=0;attempt<80&&!directory.find(dead.id()).join().orElseThrow().published();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(directory.find(dead.id()).join().orElseThrow().published());assertEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());assertEquals(java.math.BigInteger.ONE,retainedPhoenix.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN));assertTrue(persistentParties.partyOf(actor.core.record.id()).isEmpty());}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertEquals(1,events.stream().filter(event->event.get("type").getAsString().contains(".party.")).count());var ended=events.stream().filter(event->event.get("type").getAsString().endsWith("party.disbanded")&&event.getAsJsonObject("data").has("reason")&&event.getAsJsonObject("data").get("reason").getAsString().equals("final_death")).toList();assertFalse(ended.isEmpty());assertEquals(1,ended.stream().map(event->event.get("id").getAsString()).distinct().count());assertEquals(finalParty.id().toString(),ended.getFirst().getAsJsonObject("data").get("party_id").getAsString());
}
@Test void explicitPhoenixDownChoiceConsumesOneAndRescuesTheExactDownedCharacter()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);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);var inventory=(games.dmg.fantasystick.shop.RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");inventory.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();inventory.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();PartyBookNativeTest.open(f,actor);String inventoryText=PartyBookNativeTest.text(actor);assertTrue(inventoryText.contains("Phoenix Down: 2"),inventoryText);assertTrue(inventoryText.contains("Use Phoenix Down"),inventoryText);assertFalse(inventoryText.contains("[Use]"),inventoryText);actor.core.health=0;var death=death(actor);f.dispatcher.accept(death);var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&(directory.all().isEmpty()||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var episode=directory.all().getFirst();var menu=actor.inventories.getLast();assertEquals(org.bukkit.Material.FEATHER,menu.getItem(13).getType());assertTrue(plain(menu.getItem(13)).contains("Use Phoenix Down"));assertTrue(plain(menu.getItem(13)).contains("2"));var view=org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);org.mockito.Mockito.when(view.getTopInventory()).thenReturn(menu);var click=org.mockito.Mockito.mock(org.bukkit.event.inventory.InventoryClickEvent.class);org.mockito.Mockito.when(click.getView()).thenReturn(view);org.mockito.Mockito.when(click.getWhoClicked()).thenReturn(actor.player);org.mockito.Mockito.when(click.getRawSlot()).thenReturn(13);var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");controller.click(click);controller.click(click);for(int attempt=0;attempt<160&&(!directory.find(actor.core.record.id()).join().orElseThrow().published()||inventory.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN).compareTo(java.math.BigInteger.ONE)!=0||actor.player.getHealth()!=actor.maximumHealth.getValue());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var resolved=directory.find(actor.core.record.id()).join().orElseThrow();assertEquals(games.dmg.fantasystick.session.DownedEpisode.Outcome.REVIVED,resolved.outcome());assertEquals("phoenix_down",resolved.decision().source());assertEquals(episode.id(),resolved.id());assertEquals(java.math.BigInteger.ONE,inventory.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN));assertEquals(actor.maximumHealth.getValue(),actor.player.getHealth());assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).downedProtected(actor.core.id));assertFalse(new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead());assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());var protectedDamage=org.mockito.Mockito.mock(org.bukkit.event.entity.EntityDamageEvent.class);org.mockito.Mockito.when(protectedDamage.getEntity()).thenReturn(actor.player);org.mockito.Mockito.when(protectedDamage.getFinalDamage()).thenReturn(3d);controller.damage(protectedDamage);org.mockito.Mockito.verify(protectedDamage).setCancelled(true);f.plugin.setEnabled(false);var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var phoenixEvents=events.stream().filter(event->event.get("type").getAsString().endsWith("character.downed_resolved")&&event.getAsJsonObject("data").get("outcome").getAsString().equals("phoenix_down")).toList();assertFalse(phoenixEvents.isEmpty());assertEquals(1,phoenixEvents.stream().map(event->event.get("id").getAsString()).distinct().count());var phoenixData=phoenixEvents.getFirst().getAsJsonObject("data");assertEquals("downed_menu",phoenixData.get("selection_entry_point").getAsString());assertEquals("2",phoenixData.get("quantity_before").getAsString());assertEquals("1",phoenixData.get("quantity_after").getAsString());assertEquals("10.0",phoenixData.get("maximum_health").getAsString());assertTrue(events.stream().filter(event->event.get("type").getLine truncated
@@ -61,6 +61,62 @@ final class EventCaptureNativeTest {
var guarded=records.stream().filter(event->event.get("type").getAsString().contains("action.")).toList();assertEquals(10,guarded.size());for(var event:guarded){var value=event.getAsJsonObject("data");assertEquals(episodeId.toString(),value.get("downing_id").getAsString());assertEquals("downed",value.get("guard_reason").getAsString());}assertEquals(java.util.Set.of("inventory_drag","consume_item","inventory_click","dispense_armor","launch_projectile"),guarded.stream().map(event->event.getAsJsonObject("data").get("action_kind").getAsString()).collect(java.util.stream.Collectors.toSet()));assertTrue(guarded.stream().map(event->event.getAsJsonObject("data")).anyMatch(value->value.get("item_kind").getAsString().equals("quest_fishing_rod")));assertTrue(guarded.stream().map(event->event.getAsJsonObject("data")).filter(value->value.get("item_kind").getAsString().equals("control")).allMatch(value->value.get("control_kind").getAsString().equals("basic_attack")));
}
@Test void asynchronousChatUsesCapturedSessionWithoutNativeLocationLookupOrReplacementCharacter() throws Exception {
var clock=Clock.fixed(Instant.parse("2026-09-25T04:05:00Z"),ZoneOffset.UTC);
var account=UUID.randomUUID();var first=UUID.randomUUID();var session=UUID.randomUUID();var world=mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());when(world.getName()).thenReturn("world");
var player=mock(Player.class);when(player.getUniqueId()).thenReturn(account);when(player.getName()).thenReturn("Account");
when(player.getLocation()).thenReturn(new Location(world,1,64,2));
var observed=new java.util.concurrent.CopyOnWriteArrayList<games.dmg.fantasystick.events.EventInput>();
var bridge=new games.dmg.fantasystick.events.SessionEventBridge(observed::add,clock);
var record=mock(CharacterRecord.class);when(record.id()).thenReturn(first);when(record.name()).thenReturn("Original");when(record.revision()).thenReturn(java.math.BigInteger.ONE);
bridge.entered(player,session,record,new Location(world,1,64,2),false,true);
var view=new java.util.concurrent.atomic.AtomicReference<>(new games.dmg.fantasystick.presentation.CharacterPresentation.View(account,session,first,java.math.BigInteger.ONE,"Original"));
var listener=new games.dmg.fantasystick.events.MessageEventBridge(ignored->view.get(),bridge,observed::add,clock);
var body=net.kyori.adventure.text.Component.text("signed 你好 🌿");
var signed=net.kyori.adventure.chat.SignedMessage.system("signed 你好 🌿",body);
var chat=new io.papermc.paper.event.player.AsyncChatEvent(true,player,new java.util.HashSet<>(),io.papermc.paper.chat.ChatRenderer.defaultRenderer(),body,body,signed);
var failure=new java.util.concurrent.atomic.AtomicReference<Throwable>();
var worker=new Thread(()->{try{listener.chat(chat);}catch(Throwable error){failure.set(error);}},"character-message-test");worker.start();worker.join();assertNull(failure.get());
var event=observed.stream().filter(value->value.type().equals("character.chat")).findFirst().orElseThrow();
assertEquals(first,event.context().characterId());assertEquals(session,event.context().sessionId());
assertEquals("signed 你好 🌿",event.data().get("message"));assertEquals("last_native_session_capture",event.data().get("location_quality"));
view.set(new games.dmg.fantasystick.presentation.CharacterPresentation.View(account,UUID.randomUUID(),UUID.randomUUID(),java.math.BigInteger.ZERO,"Replacement"));
listener.chat(chat);assertEquals(1,observed.stream().filter(value->value.type().equals("character.chat")).count());
verify(player,times(1)).getLocation();assertSame(signed,chat.signedMessage());
}
@Test void privateAliasObservationsKeepFullBodiesWithoutClaimingRecipientDelivery() {
var clock=Clock.fixed(Instant.parse("2026-09-25T04:12:00Z"),ZoneOffset.UTC);var account=UUID.randomUUID();var character=UUID.randomUUID();var session=UUID.randomUUID();var world=mock(World.class);when(world.getUID()).thenReturn(UUID.randomUUID());when(world.getName()).thenReturn("world");
var player=mock(Player.class);when(player.getUniqueId()).thenReturn(account);when(player.getName()).thenReturn("Account");when(player.getWorld()).thenReturn(world);when(player.getLocation()).thenReturn(new Location(world,1,64,2));
var record=mock(CharacterRecord.class);when(record.id()).thenReturn(character);when(record.name()).thenReturn("Hero");when(record.revision()).thenReturn(java.math.BigInteger.ONE);
var observed=new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();var sessions=new games.dmg.fantasystick.events.SessionEventBridge(observed::add,clock);sessions.entered(player,session,record,new Location(world,1,64,2),false,false);
var listener=new games.dmg.fantasystick.events.MessageEventBridge(id->new games.dmg.fantasystick.presentation.CharacterPresentation.View(account,session,character,java.math.BigInteger.ONE,"Hero"),sessions,observed::add,clock);
var aliases=java.util.List.of("msg","message","tell","w","whisper","pm","reply","r","minecraft:msg");
for(var alias:aliases){var request=mock(org.bukkit.event.player.PlayerCommandPreprocessEvent.class);when(request.getPlayer()).thenReturn(player);when(request.getMessage()).thenReturn("/"+alias+(alias.equals("reply")||alias.equals("r")?" ":" Alex ")+"full Unicode 雪 🐉 text");listener.privateMessage(request);}
var ordinary=mock(org.bukkit.event.player.PlayerCommandPreprocessEvent.class);when(ordinary.getMessage()).thenReturn("/warp nowhere");listener.privateMessage(ordinary);
var messages=observed.stream().filter(value->value.type().equals("character.private_message")).toList();assertEquals(aliases.size(),messages.size());
for(var message:messages){assertEquals("full Unicode 雪 🐉 text",message.data().get("message"));assertEquals("unverified",message.data().get("delivery"));assertFalse(message.data().containsKey("recipient_player_id"));}
assertTrue(messages.stream().filter(value->Boolean.TRUE.equals(value.data().get("reply"))).allMatch(value->!value.data().containsKey("recipient_token")));
}
@Test void journalFailureCannotBlockSessionTransitionOrMessageHandling() {
var clock=Clock.fixed(Instant.parse("2026-09-25T04:10:00Z"),ZoneOffset.UTC);
var account=UUID.randomUUID();var character=UUID.randomUUID();var session=UUID.randomUUID();var world=mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());when(world.getName()).thenReturn("world");
var player=mock(Player.class);when(player.getUniqueId()).thenReturn(account);when(player.getName()).thenReturn("Account");
when(player.getWorld()).thenReturn(world);when(player.getLocation()).thenReturn(new Location(world,1,64,2));
var record=mock(CharacterRecord.class);when(record.id()).thenReturn(character);when(record.name()).thenReturn("Hero");when(record.revision()).thenReturn(java.math.BigInteger.ONE);
var failures=new games.dmg.fantasystick.events.SessionEventBridge(event->{throw new IllegalStateException("file unavailable");},clock);
assertDoesNotThrow(()->failures.entered(player,session,record,new Location(world,1,64,2),false,false));
var capture=failures.captureExit(player,session,record,new Location(world,1,64,2));
assertDoesNotThrow(()->failures.exited(session,capture));
var listener=new games.dmg.fantasystick.events.MessageEventBridge(id->new games.dmg.fantasystick.presentation.CharacterPresentation.View(account,session,character,java.math.BigInteger.ONE,"Hero"),failures,event->{throw new IllegalStateException("file unavailable");},clock);
var command=mock(org.bukkit.event.player.PlayerCommandPreprocessEvent.class);when(command.getPlayer()).thenReturn(player);when(command.getMessage()).thenReturn("/minecraft:msg Alex hello");
assertDoesNotThrow(()->listener.privateMessage(command));
verify(command,never()).setCancelled(anyBoolean());
}
private static int count(String value,String sought){int count=0;for(int at=0;(at=value.indexOf(sought,at))>=0;at+=sought.length())count++;return count;}
@Test void activeCharacterNativeInteractionProducesCorrelatedObservedAndCancelledRecords() throws Exception {
@@ -38,6 +38,11 @@ class FantasyFeedbackCommandNativeTest {
assertEquals("Fantasy feedback is off.", player.messages.getLast());
assertTrue(player.messageComponents.isEmpty(), "preference/status notices keep their existing presentation");
}
var records = feedbackRecords();
assertEquals(2, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).count());
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).count());
}
@Test void consoleMalformedAndExtraArgumentsCannotChangeAnAccountPreference() throws Exception {
@@ -60,6 +65,188 @@ class FantasyFeedbackCommandNativeTest {
}
}
@Test void registeredFeedbackReadAndChangeReachAccountOwnedJsonl() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick", new String[]{"feedback"}));
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick", new String[]{"feedback", "off"}));
fixture.tick();
assertEquals("Fantasy feedback is now off.", actor.messages.getLast());
fixture.plugin.setEnabled(false);
var records = feedbackRecords();
var read = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals("combat_feedback", read.get("preference_key").getAsString());
assertEquals(actor.core.id.toString(), read.get("player_id").getAsString());
assertEquals("/fstick feedback", read.get("entry_point").getAsString());
assertEquals("true", read.get("default_value").getAsString());
assertEquals("true", read.get("effective_value").getAsString());
var change = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(actor.core.id.toString(), change.get("player_id").getAsString());
assertEquals("true", change.get("before").getAsString());
assertEquals("false", change.get("after").getAsString());
assertTrue(change.get("preference_sequence").getAsLong() > 0);
assertEquals(actor.world.getUID().toString(), change.getAsJsonObject("location").get("world_id").getAsString());
}
}
@Test void feedbackRefusalsFailedAndSupersededWritesCannotClaimSuccess() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick", new String[]{"feedback", "off"}));
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick", new String[]{"feedback", "on"}));
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
preferences.set(java.util.UUID.randomUUID(), true).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS);
var callbacks = new java.util.ArrayList<Runnable>();
fixture.tasks.drainTo(callbacks);
assertEquals(2, callbacks.size());
java.util.Collections.reverse(callbacks); callbacks.forEach(Runnable::run);
assertFalse(root.onCommand(actor.player, fixture.playerCommand, "fstick", new String[]{"feedback", "OFF"}));
assertTrue(root.onCommand(org.mockito.Mockito.mock(org.bukkit.command.ConsoleCommandSender.class),
fixture.playerCommand, "fstick", new String[]{"feedback", "off"}));
assertTrue(root.onCommand(org.mockito.Mockito.mock(org.bukkit.command.BlockCommandSender.class),
fixture.playerCommand, "fstick", new String[]{"feedback", "off"}));
fixture.plugin.setEnabled(false);
var records = feedbackRecords().stream().filter(e -> e.getAsJsonObject("data").has("preference_key")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).toList();
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
assertEquals(java.util.Set.of("superseded", "unchanged", "invalid_arguments", "player_required"),
records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
.map(e -> e.getAsJsonObject("data").get("reason").getAsString())
.collect(java.util.stream.Collectors.toSet()));
fixture.plugin.setEnabled(true);
fixture.plugin.setEnabled(false);
assertEquals(0, feedbackRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
}
}
@Test void failedFeedbackSaveIsReportedWithoutChangingEffectiveAccountState() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
java.nio.file.Files.createDirectory(data.resolve("player-preferences.yml"));
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"feedback", "off"}));
fixture.tick();
assertTrue(actor.messages.getLast().contains("could not be saved"));
fixture.plugin.setEnabled(false);
assertEquals(0, feedbackRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
assertTrue(feedbackRecords().stream().anyMatch(e -> e.get("type").getAsString().endsWith("action.resolved")
&& e.getAsJsonObject("data").has("reason")
&& e.getAsJsonObject("data").get("reason").getAsString().equals("save_failed")));
}
}
@Test void closedFeedbackCommandCannotPublishUnacknowledgedPreferenceSuccess() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"feedback", "off"}));
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
preferences.set(java.util.UUID.randomUUID(), true).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS);
((games.dmg.fantasystick.session.FantasyFeedbackCommand)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackCommand")).close();
fixture.tick();
assertTrue(preferences.enabled(actor.core.id), "Closed callback leaves the current effective value unchanged");
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
fixture.plugin.setEnabled(false);
var records = feedbackRecords();
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
assertTrue(records.stream().anyMatch(e -> e.get("type").getAsString().endsWith("action.resolved")
&& e.getAsJsonObject("data").has("reason")
&& e.getAsJsonObject("data").get("reason").getAsString().equals("closed")));
}
}
@Test void feedbackOutboxRecoversOriginalAcceptedChangeAfterSinkOutage() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"feedback", "off"}));
fixture.tick();
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
for (int i = 0; i < 80 && preferences.pendingEvents().stream()
.noneMatch(p -> "true".equals(p.captured().get("published"))); i++) Thread.sleep(10);
assertTrue(preferences.pendingEvents().stream()
.anyMatch(p -> "true".equals(p.captured().get("published"))));
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
for (int i = 0; i < 80 && feedbackRecords().stream()
.noneMatch(e -> e.get("type").getAsString().endsWith("preference.changed")); i++) Thread.sleep(10);
fixture.plugin.setEnabled(false);
var changes = feedbackRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).toList();
assertEquals(1, changes.stream().map(e -> e.get("id").getAsString()).distinct().count());
assertEquals("false", changes.getFirst().getAsJsonObject("data").get("after").getAsString());
}
}
@Test void feedbackPreferenceIsAccountOwnedAcrossCharacterTransfer() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var owner = new FantasyStickLifecycleNativeTest.Actor(data);
var recipient = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(owner.core.id, owner.player); fixture.players.put(recipient.core.id, recipient.player);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(owner.player, fixture.playerCommand, "fstick", new String[]{"feedback", "off"}));
fixture.tick();
fixture.activate(owner);
var sheet = owner.core.inventory.getItemInOffHand().clone();
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
sessions.exit(owner.player);
fixture.awaitCharacterAndReceipt(owner);
recipient.core.inventory.setItem(8, recipient.core.inventory.getItemInOffHand());
recipient.core.inventory.setItemInOffHand(sheet);
fixture.activate(recipient);
assertTrue(root.onCommand(owner.player, fixture.playerCommand, "fstick", new String[]{"feedback"}));
assertEquals("Fantasy feedback is off.", owner.messages.getLast());
assertTrue(root.onCommand(recipient.player, fixture.playerCommand, "fstick", new String[]{"feedback"}));
assertEquals("Fantasy feedback is on.", recipient.messages.getLast());
fixture.plugin.setEnabled(false);
var reads = feedbackRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.map(e -> e.getAsJsonObject("data"))
.filter(d -> d.get("preference_key").getAsString().equals("combat_feedback")).toList();
assertTrue(reads.stream().anyMatch(d -> d.get("player_id").getAsString().equals(owner.core.id.toString())
&& d.get("effective_value").getAsString().equals("false") && !d.has("character_id")));
assertTrue(reads.stream().anyMatch(d -> d.get("player_id").getAsString().equals(recipient.core.id.toString())
&& d.get("effective_value").getAsString().equals("true")
&& d.get("character_id").getAsString().equals(owner.core.record.id().toString())));
assertEquals(1, feedbackRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).count());
}
}
private List<com.google.gson.JsonObject> feedbackRecords() throws Exception {
var records = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(path))
if (!line.isBlank()) records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return records;
}
@Test void sourceAndAffectedAccountPreferencesIndependentlyFilterPerTickMessages() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
@@ -287,6 +287,503 @@ class FantasyParticlesNativeTest {
}
}
@Test void registeredAllParticlesReadAndSavedChangeProduceAccountEvents() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"particles", "all"}));
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"particles", "all", "off"}));
fixture.tick();
assertEquals("All Fantasy Stick particles are now off.", actor.messages.getLast());
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
for (int attempt = 0; attempt < 80 && !preferences.pendingEvents().isEmpty(); attempt++)
Thread.sleep(10);
assertTrue(preferences.pendingEvents().isEmpty(), "First acknowledged event was not drained");
fixture.plugin.setEnabled(false);
var records = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(path))
if (!line.isBlank()) records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
var read = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.map(e -> e.getAsJsonObject("data")).filter(d -> d.get("preference_key").getAsString().equals("all-particles"))
.findFirst().orElseThrow();
assertEquals(actor.core.id.toString(), read.get("player_id").getAsString());
assertEquals("true", read.get("effective_value").getAsString());
var changed = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
.map(e -> e.getAsJsonObject("data")).filter(d -> d.get("preference_key").getAsString().equals("all-particles"))
.findFirst().orElseThrow();
assertEquals("true", changed.get("before").getAsString());
assertEquals("false", changed.get("after").getAsString());
assertTrue(changed.get("preference_sequence").getAsLong() > 0);
assertEquals(read.get("player_id"), changed.get("player_id"));
fixture.plugin.setEnabled(true);
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"particles", "all", "on"}));
for (int attempt = 0; attempt < 80 && !actor.messages.getLast().contains("now on"); attempt++) {
var callback = fixture.tasks.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS);
if (callback != null) callback.run();
}
assertEquals("All Fantasy Stick particles are now on.", actor.messages.getLast());
fixture.plugin.setEnabled(false);
var changes = preferenceRecords().stream()
.filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
.map(e -> e.getAsJsonObject("data"))
.filter(d -> d.get("preference_key").getAsString().equals("all-particles"))
.toList();
var logical = changes.stream().collect(java.util.stream.Collectors.toMap(
d -> d.get("action_id").getAsString(), d -> d, (first, duplicate) -> first,
java.util.LinkedHashMap::new));
assertEquals(2, logical.size(), "At-least-once export may duplicate a physical line, not a logical change");
assertTrue(logical.values().stream().mapToLong(d -> d.get("preference_sequence").getAsLong()).max().orElseThrow()
> changed.get("preference_sequence").getAsLong(),
"The acknowledged preference revision must stay ordered across restart");
}
}
@Test void refusedAndSupersededAllParticleWritesNeverClaimAnAcceptedChange() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all", "off"}));
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all", "on"}));
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
preferences.setAllParticles(java.util.UUID.randomUUID(), true).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS);
var callbacks = new java.util.ArrayList<Runnable>();
fixture.tasks.drainTo(callbacks);
assertEquals(2, callbacks.size());
java.util.Collections.reverse(callbacks); callbacks.forEach(Runnable::run);
assertTrue(preferences.allParticlesEnabled(actor.core.id));
assertFalse(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all", "invalid"}));
fixture.plugin.setEnabled(false);
var records = preferenceRecords();
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
var resolved = records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
.map(e -> e.getAsJsonObject("data"))
.filter(d -> d.has("preference_key") && d.get("preference_key").getAsString().equals("all-particles"))
.toList();
assertEquals(java.util.Set.of("superseded", "unchanged", "invalid_arguments"), resolved.stream()
.map(d -> d.get("reason").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertEquals(3, resolved.size());
fixture.plugin.setEnabled(true);
fixture.plugin.setEnabled(false);
assertEquals(0, preferenceRecords().stream()
.filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count(),
"Superseded and unchanged saves cannot become successes during restart recovery");
}
}
@Test void failedAllParticleSaveRemainsObservableWithoutChangingGameplay() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
java.nio.file.Files.createDirectory(data.resolve("player-preferences.yml"));
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"particles", "all", "off"}));
fixture.tick();
assertTrue(actor.messages.getLast().contains("could not be saved"));
fixture.plugin.setEnabled(false);
var records = preferenceRecords();
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved")
&& e.getAsJsonObject("data").has("reason")
&& e.getAsJsonObject("data").get("reason").getAsString().equals("save_failed")).count());
}
}
@Test void accountOptOutRetainsAuditAndCapturesCurrentCharacterWithoutBorrowingAnotherAccount() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
var other = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
fixture.players.put(other.core.id, other.player);
fixture.activate(actor);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all", "off"}));
fixture.tick();
assertTrue(root.onCommand(other.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all"}));
assertEquals("All Fantasy Stick particles are on.", other.messages.getLast());
var sourceSheet = actor.core.inventory.getItemInOffHand().clone();
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
sessions.exit(actor.player);
fixture.awaitCharacterAndReceipt(actor);
other.core.inventory.setItem(8, other.core.inventory.getItemInOffHand());
other.core.inventory.setItemInOffHand(sourceSheet);
fixture.activate(other);
assertTrue(root.onCommand(other.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all"}));
assertEquals("All Fantasy Stick particles are on.", other.messages.getLast());
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all"}));
assertEquals("All Fantasy Stick particles are off.", actor.messages.getLast());
assertEquals(List.of("off"), fixture.playerCommand.getTabCompleter().onTabComplete(actor.player,
fixture.playerCommand, "fstick", new String[]{"particles", "all", "of"}));
fixture.plugin.setEnabled(false);
var records = preferenceRecords();
var changed = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
.map(e -> e.getAsJsonObject("data")).toList();
assertEquals(1, changed.size(), "Particle delivery and autocomplete do not create audit events");
assertEquals(actor.core.id.toString(), changed.getFirst().get("player_id").getAsString());
assertEquals(actor.core.record.id().toString(), changed.getFirst().get("character_id").getAsString());
assertEquals(actor.world.getUID().toString(), changed.getFirst().getAsJsonObject("location")
.get("world_id").getAsString());
var read = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.map(e -> e.getAsJsonObject("data"))
.filter(d -> d.get("player_id").getAsString().equals(other.core.id.toString()))
.findFirst().orElseThrow();
assertFalse(read.has("character_id"), "An ordinary viewer cannot borrow a playing character identity");
assertEquals("true", read.get("effective_value").getAsString());
assertTrue(records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.map(e -> e.getAsJsonObject("data"))
.anyMatch(d -> d.get("player_id").getAsString().equals(other.core.id.toString())
&& d.has("character_id")
&& d.get("character_id").getAsString().equals(actor.core.record.id().toString())
&& d.get("effective_value").getAsString().equals("true")),
"A transferred sheet carries its character, not the previous account's particle preference");
}
}
@Test void nonPlayerAllParticleRequestsAreTruthfullyRefusedWithoutInventingPreferenceSuccess() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var console = org.mockito.Mockito.mock(org.bukkit.command.ConsoleCommandSender.class);
var block = org.mockito.Mockito.mock(org.bukkit.command.BlockCommandSender.class);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(console, fixture.playerCommand, "fstick",
new String[]{"particles", "all", "off"}));
assertTrue(root.onCommand(block, fixture.playerCommand, "fstick",
new String[]{"particles", "all", "off"}));
fixture.plugin.setEnabled(false);
var records = preferenceRecords();
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().contains(".preference.")).count());
var refused = records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
.map(e -> e.getAsJsonObject("data"))
.filter(d -> d.has("reason") && d.get("reason").getAsString().equals("player_required"))
.toList();
assertEquals(java.util.Set.of("console", "command_block"), refused.stream()
.map(d -> d.get("actor_kind").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertEquals(2, refused.size());
assertTrue(refused.stream().allMatch(d -> d.get("location_status").getAsString().equals("not_applicable")));
}
}
@Test void acceptedPreferenceOutlivesUnavailableEventSinkWithoutLosingItsOriginalEvent() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
var log = (games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog");
log.close(); // Logging fails after gameplay has started; the saved preference remains authoritative.
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"particles", "all", "off"}));
fixture.tick();
assertEquals("All Fantasy Stick particles are now off.", actor.messages.getLast());
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
for (int attempt = 0; attempt < 80 && preferences.pendingEvents().stream()
.noneMatch(intent -> "true".equals(intent.captured().get("published"))); attempt++)
Thread.sleep(10);
assertTrue(preferences.pendingEvents().stream()
.anyMatch(intent -> "true".equals(intent.captured().get("published"))),
"Committed publication proof must be durable before orderly restart");
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
for (int attempt = 0; attempt < 80 && preferenceRecords().stream()
.noneMatch(e -> e.get("type").getAsString().endsWith("preference.changed")); attempt++)
Thread.sleep(10);
fixture.plugin.setEnabled(false);
var events = preferenceRecords();
var changes = events.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
.filter(e -> e.getAsJsonObject("data").get("preference_key").getAsString().equals("all-particles"))
.toList();
assertEquals(1, changes.stream().map(e -> e.get("id").getAsString()).distinct().count(),
"An acknowledged account change must retain one recoverable event identity");
assertEquals("true", changes.getFirst().getAsJsonObject("data").get("before").getAsString());
assertEquals("false", changes.getFirst().getAsJsonObject("data").get("after").getAsString());
}
}
@Test void readDuringDeferredPublicationDistinguishesSavedFromEffectivePreference() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all", "off"}));
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
preferences.setAllParticles(java.util.UUID.randomUUID(), true).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS); // worker barrier; do not execute deferred main callback
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all"}));
assertEquals("All Fantasy Stick particles are on.", actor.messages.getLast());
fixture.plugin.setEnabled(false);
var read = preferenceRecords().stream()
.filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.map(e -> e.getAsJsonObject("data"))
.filter(d -> d.get("player_id").getAsString().equals(actor.core.id.toString()))
.findFirst().orElseThrow();
assertEquals("false", read.get("saved_value").getAsString());
assertEquals("true", read.get("effective_value").getAsString());
assertEquals(0, preferenceRecords().stream()
.filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count(),
"A queued native callback has not yet published a changed outcome");
}
}
@Test void twoAcknowledgedChangesSurviveEventSinkFailureWithTheirOriginalIdentities() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
var root = fixture.playerCommand.getExecutor();
for (String value : List.of("off", "on")) {
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all", value}));
for (int attempt = 0; attempt < 80 && (actor.messages.isEmpty()
|| !actor.messages.getLast().contains("now " + value)); attempt++) {
var callback = fixture.tasks.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS);
if (callback != null) callback.run();
}
assertTrue(actor.messages.getLast().contains("now " + value));
}
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
for (int attempt = 0; attempt < 80 && preferences.pendingEvents().stream()
.filter(intent -> "true".equals(intent.captured().get("published"))).count() < 2; attempt++)
Thread.sleep(10);
assertEquals(2, preferences.pendingEvents().stream()
.filter(intent -> "true".equals(intent.captured().get("published"))).count());
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
for (int attempt = 0; attempt < 80 && preferenceRecords().stream()
.filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
.map(e -> e.get("id").getAsString()).distinct().count() < 2; attempt++)
Thread.sleep(10);
fixture.plugin.setEnabled(false);
var changes = preferenceRecords().stream()
.filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
.collect(java.util.stream.Collectors.toMap(e -> e.get("id").getAsString(), e -> e,
(first, duplicate) -> first, java.util.LinkedHashMap::new));
assertEquals(2, changes.size(), "Both acknowledged changes need distinct stable recovered outcomes");
assertEquals(java.util.Set.of("false", "true"), changes.values().stream()
.map(e -> e.getAsJsonObject("data").get("after").getAsString())
.collect(java.util.stream.Collectors.toSet()));
}
}
@Test void registeredAuraPreferenceRecordsDefaultOffAndSavedOptInUnderAllOff() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players"}));
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all", "off"}));
fixture.tick();
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players", "on"}));
fixture.tick();
assertTrue(actor.messages.getLast().contains("effectively off"));
fixture.plugin.setEnabled(false);
var records = preferenceRecords();
var aura = records.stream().filter(e -> e.getAsJsonObject("data").has("preference_key")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("player-aura-particles"))
.toList();
var read = aura.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals("false", read.get("default_value").getAsString());
assertEquals("false", read.get("saved_value").getAsString());
assertEquals("false", read.get("effective_value").getAsString());
assertEquals("/fstick particles players", read.get("entry_point").getAsString());
var change = aura.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(actor.core.id.toString(), change.get("player_id").getAsString());
assertEquals("false", change.get("saved_before").getAsString());
assertEquals("true", change.get("saved_value").getAsString());
assertEquals("false", change.get("effective_value").getAsString());
assertEquals("true", change.get("after").getAsString());
assertTrue(change.get("preference_sequence").getAsLong() > 0);
}
}
@Test void auraRefusalsAndSupersededWritesNeverClaimPreferenceSuccess() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players", "on"}));
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players", "off"}));
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
preferences.setPlayerAuras(java.util.UUID.randomUUID(), true).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS);
var callbacks = new java.util.ArrayList<Runnable>();
fixture.tasks.drainTo(callbacks);
assertEquals(2, callbacks.size());
java.util.Collections.reverse(callbacks); callbacks.forEach(Runnable::run);
assertFalse(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players", "invalid"}));
assertTrue(root.onCommand(org.mockito.Mockito.mock(org.bukkit.command.ConsoleCommandSender.class),
fixture.playerCommand, "fstick", new String[]{"particles", "players", "on"}));
assertTrue(root.onCommand(org.mockito.Mockito.mock(org.bukkit.command.BlockCommandSender.class),
fixture.playerCommand, "fstick", new String[]{"particles", "players", "on"}));
fixture.plugin.setEnabled(false);
var records = preferenceRecords().stream().filter(e -> e.getAsJsonObject("data").has("preference_key")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("player-aura-particles")).toList();
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
var reasons = records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
.map(e -> e.getAsJsonObject("data").get("reason").getAsString()).toList();
assertEquals(5, reasons.size());
assertEquals(java.util.Set.of("superseded", "unchanged", "invalid_arguments", "player_required"),
java.util.Set.copyOf(reasons));
fixture.plugin.setEnabled(true);
fixture.plugin.setEnabled(false);
assertEquals(0, preferenceRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
}
}
@Test void auraSaveFailureLeavesDefaultOffAndReportsFailure() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
java.nio.file.Files.createDirectory(data.resolve("player-preferences.yml"));
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"particles", "players", "on"}));
fixture.tick();
assertTrue(actor.messages.getLast().contains("could not be saved"));
fixture.plugin.setEnabled(false);
var records = preferenceRecords().stream().filter(e -> e.getAsJsonObject("data").has("preference_key")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("player-aura-particles")).toList();
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
assertEquals("save_failed", records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
.findFirst().orElseThrow().getAsJsonObject("data").get("reason").getAsString());
}
}
@Test void auraOptInSurvivesRestartAndCharacterTransferWithoutLeakingAccounts() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var owner = new FantasyStickLifecycleNativeTest.Actor(data);
var receiver = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(owner.core.id, owner.player);
fixture.players.put(receiver.core.id, receiver.player);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(owner.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players", "on"}));
fixture.tick();
fixture.activate(owner);
var sheet = owner.core.inventory.getItemInOffHand().clone();
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
sessions.exit(owner.player);
fixture.awaitCharacterAndReceipt(owner);
receiver.core.inventory.setItem(8, receiver.core.inventory.getItemInOffHand());
receiver.core.inventory.setItemInOffHand(sheet);
fixture.activate(receiver);
assertTrue(root.onCommand(receiver.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players"}));
assertEquals("Active-character aura particles are off.", receiver.messages.getLast());
assertTrue(root.onCommand(owner.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players"}));
assertEquals("Active-character aura particles are on.", owner.messages.getLast());
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
assertTrue(root.onCommand(receiver.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players"}));
assertEquals("Active-character aura particles are off.", receiver.messages.getLast());
assertTrue(root.onCommand(owner.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players"}));
assertEquals("Active-character aura particles are on.", owner.messages.getLast());
assertEquals(List.of("off"), fixture.playerCommand.getTabCompleter().onTabComplete(owner.player,
fixture.playerCommand, "fstick", new String[]{"particles", "players", "of"}));
fixture.plugin.setEnabled(false);
var records = preferenceRecords().stream()
.filter(e -> e.getAsJsonObject("data").has("preference_key")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("player-aura-particles"))
.toList();
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
var receiverRead = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.map(e -> e.getAsJsonObject("data"))
.filter(d -> d.get("player_id").getAsString().equals(receiver.core.id.toString()))
.findFirst().orElseThrow();
assertEquals("false", receiverRead.get("saved_value").getAsString());
assertEquals(owner.core.record.id().toString(), receiverRead.get("character_id").getAsString());
assertTrue(records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.map(e -> e.getAsJsonObject("data"))
.anyMatch(d -> d.get("player_id").getAsString().equals(owner.core.id.toString())
&& d.get("saved_value").getAsString().equals("true")));
}
}
@Test void acceptedAuraOptInRecoversSameEventAfterSinkOutage() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"particles", "players", "on"}));
fixture.tick();
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
for (int i = 0; i < 80 && preferences.pendingEvents().stream()
.noneMatch(p -> "true".equals(p.captured().get("published"))); i++) Thread.sleep(10);
assertTrue(preferences.pendingEvents().stream()
.anyMatch(p -> "true".equals(p.captured().get("published"))));
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
for (int i = 0; i < 80 && preferenceRecords().stream()
.noneMatch(e -> e.get("type").getAsString().endsWith("preference.changed")); i++) Thread.sleep(10);
fixture.plugin.setEnabled(false);
var changes = preferenceRecords().stream()
.filter(e -> e.get("type").getAsString().endsWith("preference.changed")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("player-aura-particles"))
.toList();
assertEquals(1, changes.stream().map(e -> e.get("id").getAsString()).distinct().count());
assertEquals("false", changes.getFirst().getAsJsonObject("data").get("saved_before").getAsString());
assertEquals("true", changes.getFirst().getAsJsonObject("data").get("saved_value").getAsString());
}
}
private java.util.List<com.google.gson.JsonObject> preferenceRecords() throws Exception {
var records = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(path))
if (!line.isBlank()) records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return records;
}
private static void optIn(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
fixture.particlesCommand.execute(actor.player, "fantasyparticles", new String[]{"players", "on"});
@@ -101,6 +101,17 @@ class FantasyStickLifecycleNativeTest {
assertTrue(f.order.contains("EventCaptureListener"));
assertTrue(f.order.indexOf("EventCaptureListener") < f.order.indexOf("recipe"));
var actor = new Actor(data); f.players.put(actor.core.id, actor.player); f.activate(actor);
var damage = mock(org.bukkit.event.entity.EntityDamageEvent.class);
when(damage.getHandlers()).thenReturn(org.bukkit.event.entity.EntityDamageEvent.getHandlerList());
when(damage.getEntity()).thenReturn(actor.player);
when(damage.getCause()).thenReturn(org.bukkit.event.entity.EntityDamageEvent.DamageCause.LAVA);
when(damage.getFinalDamage()).thenReturn(2.5);
f.dispatcher.accept(damage);
var refusedDamage = mock(org.bukkit.event.entity.EntityDamageEvent.class);
when(refusedDamage.getHandlers()).thenReturn(org.bukkit.event.entity.EntityDamageEvent.getHandlerList());
when(refusedDamage.getEntity()).thenReturn(actor.player);
when(refusedDamage.isCancelled()).thenReturn(true);
f.dispatcher.accept(refusedDamage);
var event = new org.bukkit.event.player.PlayerInteractEvent(actor.player,
org.bukkit.event.block.Action.LEFT_CLICK_AIR, actor.core.inventory.getItem(0), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
@@ -109,6 +120,13 @@ class FantasyStickLifecycleNativeTest {
org.bukkit.event.block.Action.LEFT_CLICK_AIR, actor.core.inventory.getItem(0), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.OFF_HAND);
f.dispatcher.accept(duplicate);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) Fixture.field(f.plugin, "sessions");
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.RESTORED, sessions.exit(actor.player));
for (int attempt = 0; attempt < 80 && f.receipts().find(actor.core.id).toCompletableFuture().get().orElseThrow().pending() != null; attempt++) {
var callback = f.tasks.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS);
if (callback != null) callback.run();
}
assertNull(f.receipts().find(actor.core.id).toCompletableFuture().get().orElseThrow().pending());
f.plugin.setEnabled(false);
String records;
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
@@ -119,6 +137,11 @@ class FantasyStickLifecycleNativeTest {
records = text.toString();
}
assertTrue(records.contains("games.dmg.minecraft.fantasystick.game.started"));
assertTrue(records.contains("games.dmg.minecraft.fantasystick.character.entered"), "Native saved-session activation must reach JSONL");
assertTrue(records.contains("games.dmg.minecraft.fantasystick.character.exited"), "Living exit must follow acknowledged restoration");
assertTrue(records.contains("games.dmg.minecraft.fantasystick.combat.damage"), "Accepted environmental damage must reach JSONL");
assertTrue(records.contains("\"amount\":\"2.5\""));
assertEquals(1, records.split("games.dmg.minecraft.fantasystick.combat.damage", -1).length - 1);
assertTrue(records.contains("games.dmg.minecraft.fantasystick.action.observed"));
assertTrue(records.contains("games.dmg.minecraft.fantasystick.action.resolved"));
assertTrue(records.contains("games.dmg.minecraft.fantasystick.game.stopped"));
@@ -131,6 +154,39 @@ class FantasyStickLifecycleNativeTest {
}
}
@Test void activePublicChatAndExplicitPrivateCommandsReachLocalJournalWithoutChangingSignedContent() throws Exception {
try(var f=new Fixture(data)) {
f.plugin.setEnabled(true);
var actor=new Actor(data);f.players.put(actor.core.id,actor.player);f.activate(actor);
var body=net.kyori.adventure.text.Component.text("Hello 雪 🌿");
var signed=net.kyori.adventure.chat.SignedMessage.system("Hello 雪 🌿",body);
var chat=new io.papermc.paper.event.player.AsyncChatEvent(false,actor.player,new java.util.HashSet<>(),
io.papermc.paper.chat.ChatRenderer.defaultRenderer(),body,body,signed);
f.dispatcher.accept(chat);
assertSame(signed,chat.signedMessage());assertSame(body,chat.originalMessage());
var cancelled=new io.papermc.paper.event.player.AsyncChatEvent(false,actor.player,new java.util.HashSet<>(),
io.papermc.paper.chat.ChatRenderer.defaultRenderer(),body,body,signed);
cancelled.setCancelled(true);f.dispatcher.accept(cancelled);assertTrue(cancelled.isCancelled());
for (var command : java.util.List.of("/msg Alex 私信 🌿", "/r thank you", "/warp secret")) {
var request = mock(org.bukkit.event.player.PlayerCommandPreprocessEvent.class);
when(request.getHandlers()).thenReturn(org.bukkit.event.player.PlayerCommandPreprocessEvent.getHandlerList());
when(request.getPlayer()).thenReturn(actor.player);
when(request.getMessage()).thenReturn(command);
when(request.isCancelled()).thenReturn(command.startsWith("/r "));
f.dispatcher.accept(request);
}
f.plugin.setEnabled(false);
}
var rows=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())rows.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}
var chat=rows.stream().filter(row->row.get("type").getAsString().endsWith("character.chat")).toList();
assertEquals(2,chat.size());assertEquals("Hello 雪 🌿",chat.getFirst().getAsJsonObject("data").get("message").getAsString());assertTrue(chat.stream().anyMatch(row->row.getAsJsonObject("data").get("cancelled").getAsBoolean()));
var privateMessages=rows.stream().filter(row->row.get("type").getAsString().endsWith("character.private_message")).toList();
assertEquals(2,privateMessages.size());assertEquals(java.util.Set.of("私信 🌿","thank you"),privateMessages.stream().map(row->row.getAsJsonObject("data").get("message").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertTrue(privateMessages.stream().allMatch(row->row.getAsJsonObject("data").get("delivery").getAsString().equals("unverified")));
assertTrue(privateMessages.stream().anyMatch(row->row.getAsJsonObject("data").get("cancelled").getAsBoolean()));
assertTrue(privateMessages.stream().allMatch(row->row.get("characterid").getAsString().equals(chat.getFirst().get("characterid").getAsString())));
}
@Test void registeredBookInputOpensAuthoritativeNativePagesWithoutGivingOrChangingItems() throws Exception {
try (var f = new Fixture(data)) {
f.plugin.setEnabled(true);
@@ -1405,7 +1461,8 @@ class FantasyStickLifecycleNativeTest {
}
void activate(Actor actor) throws Exception {
changed(actor); tick(); tick(); tick();
assertTrue(actor.core.marker().endsWith("|PACKED"));
for(int attempt=0;attempt<80&&(actor.core.marker()==null||!actor.core.marker().endsWith("|PACKED"));attempt++)tick();
assertNotNull(actor.core.marker());assertTrue(actor.core.marker().endsWith("|PACKED"));
}
private void captureWorkers(JavaPlugin instance) {
for (String name : List.of("characters", "receipts", "adventureSettings", "revivals", "trainingDummies",
@@ -128,6 +128,69 @@ class FireballNativeTest {
assertEquals(1, launches.get(), "Exit/resume and control reconstruction preserve the same Fireball deadline");
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("fireball"));
}
var events = events(data);
for (var suffix : java.util.List.of("ability.learned", "ability.assigned", "ability.cast",
"ability.resolved", "combat.damage"))
assertEquals(1, events.stream().filter(event -> event.get("type").getAsString().endsWith(suffix)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("fireball")).count(), suffix);
var castEvent = events.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("fireball")).findFirst().orElseThrow();
var resolution = events.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("fireball")).findFirst().orElseThrow();
assertEquals(castEvent.getAsJsonObject("data").get("action_id").getAsString(),
resolution.getAsJsonObject("data").get("action_id").getAsString());
assertEquals("hit", resolution.getAsJsonObject("data").get("outcome").getAsString());
assertEquals("12", castEvent.getAsJsonObject("data").get("captured_int").getAsString());
assertEquals("6", castEvent.getAsJsonObject("data").getAsJsonObject("captured_settings")
.get("base-damage").getAsString());
assertEquals("definition_at_request", castEvent.getAsJsonObject("data").get("settings_scope").getAsString());
assertEquals("6", resolution.getAsJsonObject("data").getAsJsonObject("effective_settings")
.get("base-damage").getAsString());
assertTrue(resolution.getAsJsonObject("data").has("direct_entity_id"));
assertTrue(resolution.getAsJsonObject("data").has("target_location"));
var damage = events.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("fireball")).findFirst().orElseThrow();
assertEquals("7.0", damage.getAsJsonObject("data").get("actual_amount").getAsString());
assertEquals(castEvent.getAsJsonObject("data").get("action_id").getAsString(),
damage.getAsJsonObject("data").get("action_id").getAsString());
}
@Test void acceptedFantasyPlayerHitHasOneOwnedDamageRecordNotAnIncomingDuplicate() 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);
withFireball(data, a, 12);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
f.activate(a); f.activate(b);
var worldId = UUID.randomUUID();
org.mockito.Mockito.when(a.world.getUID()).thenReturn(worldId);
b.core.location.setWorld(a.world); b.core.location.setX(10); b.core.location.setY(65); b.core.location.setZ(0);
org.mockito.Mockito.doReturn(org.bukkit.entity.EntityType.PLAYER).when(b.player).getType();
var arrow = projectile(a, new org.bukkit.Location(a.world, 10, 65.6, 0));
prepareLaunches(f, a, arrow);
var hits = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
org.mockito.Mockito.doAnswer(call -> {
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(arrow, b.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE,
(org.bukkit.damage.DamageSource) call.getArgument(1), (Double) call.getArgument(0));
f.dispatcher.accept(event); hits.add(event); return null;
}).when(b.player).damage(org.mockito.ArgumentMatchers.anyDouble(),
org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
var eye = new org.bukkit.Location(a.world, 0, 65.6, 0);
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
org.mockito.Mockito.doReturn(eye).when(a.player).getEyeLocation();
cast(f, a);
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(arrow, b.player));
assertEquals(1, hits.size()); assertFalse(hits.getFirst().isCancelled());
}
var damage = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
&& event.getAsJsonObject("data").has("target_player_id")).toList();
assertEquals(1, damage.size(), "The native incoming observer must yield to the ability owner");
assertEquals("fireball", damage.getFirst().getAsJsonObject("data").get("ability_id").getAsString());
assertEquals("7.0", damage.getFirst().getAsJsonObject("data").get("actual_amount").getAsString());
}
@SuppressWarnings("unchecked")
@@ -258,6 +321,12 @@ class FireballNativeTest {
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")),
"Transfer/reload must retain the original Fireball deadline after refund and relearn");
}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var unlearned=events.stream().filter(event->event.get("type").getAsString().endsWith("ability.unlearned")).toList();assertEquals(1,unlearned.size());var value=unlearned.getFirst().getAsJsonObject("data");assertEquals("fireball",value.get("ability_id").getAsString());assertEquals("39",value.get("points_before").getAsString());assertEquals("40",value.get("points_after").getAsString());assertEquals(java.util.List.of(0),value.getAsJsonArray("cleared_slot_indexes").asList().stream().map(com.google.gson.JsonElement::getAsInt).toList());assertEquals("acknowledged",value.get("native_publication").getAsString());assertEquals("invalidation_acknowledged_close_attempted",value.get("owned_cleanup").getAsString());assertEquals(value.get("cooldown_before").getAsString(),value.get("cooldown_after").getAsString());assertTrue(value.getAsJsonObject("location").has("world_id"));var action=value.get("action_id").getAsString();assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("action.observed")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("ability_unlearn")&&event.getAsJsonObject("data").get("action_id").getAsString().equals(action)).count());assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("ability_unlearn")&&event.getAsJsonObject("data").get("action_id").getAsString().equals(action)&&event.getAsJsonObject("data").get("outcome").getAsString().equals("accepted")).count());assertTrue(events.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("ability_unlearn")).map(event->event.getAsJsonObject("data")).anyMatch(data->data.get("outcome").getAsString().equals("refused")&&data.get("reason").getAsString().equals("stale_forged_or_repeated_action")));
}
@Test void failedBookUnlearnResolvesWithoutInventingARefundEvent() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withFireball(data,a,12);f.players.put(a.core.id,a.player);f.activate(a);PartyBookNativeTest.open(f,a);var unlearn=PartyBookNativeTest.button(a,"[Unlearn]");try(var channel=java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"),java.nio.file.StandardOpenOption.WRITE);var lock=channel.lock()){assertTrue(lock.isValid());PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertTrue(storage.find(a.core.record.id()).get().orElseThrow().learned().contains("fireball"));f.plugin.setEnabled(false);f.awaitWorkers();}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertEquals(0,events.stream().filter(event->event.get("type").getAsString().endsWith("ability.unlearned")).count());assertTrue(events.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("ability_unlearn")).map(event->event.getAsJsonObject("data")).anyMatch(value->value.get("outcome").getAsString().equals("failed")&&value.get("reason").getAsString().equals("save_or_native_cleanup_uncertain")));
}
@Test void partyAndMixedModeProjectileHitsPreserveProtectionCancellation() throws Exception {
@@ -290,6 +359,14 @@ class FireballNativeTest {
assertTrue(org.mockito.Mockito.mockingDetails(a.world).getInvocations().stream()
.noneMatch(invocation -> invocation.getMethod().getName().equals("createExplosion")));
}
var observed = events(data);
assertEquals(2, observed.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("fireball")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("cancelled")).count());
assertEquals(0, observed.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("fireball")).count());
}
@Test void rangeLifetimeAndSessionEndEachRemoveOwnedProjectileWithoutLateDamage() throws Exception {
@@ -327,6 +404,16 @@ class FireballNativeTest {
((games.dmg.fantasystick.ability.AbilityEngine) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityEngine")).sweep();
org.mockito.Mockito.verify(exiting, org.mockito.Mockito.atLeastOnce()).remove();
}
var outcomes = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("fireball"))
.map(event -> event.getAsJsonObject("data").get("outcome").getAsString()).toList();
assertEquals(4, outcomes.size(), outcomes.toString());
assertEquals(2, outcomes.stream().filter("miss"::equals).count());
assertTrue(outcomes.contains("expired")); assertTrue(outcomes.contains("interrupted"));
assertEquals(0, events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("fireball")).count());
}
@Test void validatedConfiguredBalanceReachesTheRegisteredDefinition() throws Exception {
@@ -345,6 +432,12 @@ class FireballNativeTest {
assertEquals(new java.math.BigDecimal("4"), definition.parameters().get("projectile-speed"));
assertTrue(String.join(" ", definition.lore()).contains("Deals 9"));
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = true;
f.command.execute(admin.port, "fstickadmin",
new String[]{"config", "fireball-projectile-speed-blocks-per-tick"});
admin.allowed = false;
f.command.execute(admin.port, "fstickadmin",
new String[]{"config", "fireball-projectile-speed-blocks-per-tick"});
admin.allowed = true;
assertEquals(java.util.List.of("fireball-projectile-speed-blocks-per-tick"), f.command.tabComplete(admin.port,
"fantasystick", new String[]{"config", "fireball-projectile-s"}));
assertEquals(java.util.List.of("3", "4"), f.command.tabComplete(admin.port, "fantasystick",
@@ -356,7 +449,28 @@ class FireballNativeTest {
assertEquals(new java.math.BigDecimal("4.5"), updated.parameters().get("projectile-speed"));
assertTrue(java.nio.file.Files.readString(data.resolve("config.yml"))
.contains("fireball-projectile-speed-blocks-per-tick: '4.5'"));
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", "fireball-projectile-speed-blocks-per-tick", "5"});
var failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(failed); failed.run();
}
assertEquals("4.5", ((games.dmg.fantasystick.persistence.FireballSettings)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "fireballSettings"))
.value("fireball-projectile-speed-blocks-per-tick"));
assertTrue(admin.messages.getLast().contains("save failed"));
}
var config = events(data).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("fireball-projectile-speed-blocks-per-tick")).toList();
assertEquals(2, config.size());
assertTrue(config.getFirst().get("type").getAsString().endsWith("config.read"));
assertEquals("4", config.getLast().getAsJsonObject("data").get("before").getAsString());
assertEquals("4.5", config.getLast().getAsJsonObject("data").get("after").getAsString());
assertEquals("console", config.getLast().getAsJsonObject("data").get("actor_kind").getAsString());
}
@Test void liveSpeedUpdateAppliesOnlyToFutureCastsAndRetainsActiveArrow() throws Exception {
@@ -467,6 +581,12 @@ class FireballNativeTest {
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), record.id());
var player = org.mockito.Mockito.mock(org.bukkit.entity.Player.class);
var world = org.mockito.Mockito.mock(org.bukkit.World.class);
org.mockito.Mockito.when(world.getUID()).thenReturn(UUID.randomUUID());
org.mockito.Mockito.when(world.getName()).thenReturn("world");
org.mockito.Mockito.when(player.getUniqueId()).thenReturn(active.player());
org.mockito.Mockito.when(player.getName()).thenReturn("Caster");
org.mockito.Mockito.when(player.getLocation()).thenReturn(new org.bukkit.Location(world, 1, 64, 2));
org.mockito.Mockito.when(sessions.current(active)).thenReturn(true);
org.mockito.Mockito.when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
var failed = new java.util.concurrent.CompletableFuture<games.dmg.fantasystick.persistence.CharacterStorage.AbilityMutation>();
@@ -477,6 +597,10 @@ class FireballNativeTest {
var failures = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityEngine.Failure>();
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties,
java.time.Clock.systemUTC(), Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failures::add);
var audit = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
engine.attachCastEvents(new games.dmg.fantasystick.events.AbilityCastEventBridge(audit::add,
java.time.Clock.systemUTC()));
engine.enableCastEventsFor("fireball");
engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "fireball",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
games.dmg.fantasystick.session.ControlInput.Gesture.USE, null, 0, 0, () -> true));
@@ -485,6 +609,10 @@ class FireballNativeTest {
org.mockito.Mockito.verify(sessions, org.mockito.Mockito.never()).acknowledgeProgress(
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
assertEquals(1, failures.size());
assertEquals(java.util.List.of("action.observed", "ability.resolved"),
audit.stream().map(games.dmg.fantasystick.events.EventInput::type).toList());
assertEquals("failed", audit.getLast().data().get("outcome"));
assertEquals(audit.getFirst().actionId(), audit.getLast().actionId());
engine.close(); fireball.close(); parties.close();
}
@@ -521,6 +649,16 @@ class FireballNativeTest {
}
}
private static java.util.List<com.google.gson.JsonObject> events(Path data) throws Exception {
var events = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(file))
if (!line.isBlank()) events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return events;
}
private static void await(java.util.concurrent.atomic.AtomicInteger value, int expected) throws Exception {
for (int attempt = 0; attempt < 500 && value.get() < expected; attempt++) Thread.sleep(10);
assertEquals(expected, value.get());
@@ -596,6 +734,7 @@ class FireballNativeTest {
org.bukkit.Location location) {
var projectile = org.mockito.Mockito.mock(org.bukkit.entity.Arrow.class);
org.mockito.Mockito.when(projectile.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
org.mockito.Mockito.when(projectile.getType()).thenReturn(org.bukkit.entity.EntityType.ARROW);
org.mockito.Mockito.when(projectile.getShooter()).thenReturn(actor.player);
org.mockito.Mockito.when(projectile.getLocation()).thenAnswer(ignored -> location.clone());
org.mockito.Mockito.when(projectile.getWorld()).thenReturn(location.getWorld());
@@ -99,6 +99,45 @@ class FrostNovaNativeTest {
assertEquals(60L, fixture.delayedTicks.getLast()); fixture.delayed.getLast().run();
assertFalse(enemyEffects.containsKey(PotionEffectType.SLOWNESS)); assertFalse(opponentEffects.containsKey(PotionEffectType.SLOWNESS));
}
var records = events(data);
for (var type : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved"))
assertEquals(1, abilityEvents(records, type).size(), type);
assertEquals(2, abilityEvents(records, "combat.damage").size(), "One accepted record per eligible target");
for (var type : List.of("effect.applied", "effect.ended"))
assertEquals(2, abilityEvents(records, type).size(), "Only the two owned Slowness layers: " + type);
var damageEvents = abilityEvents(records, "combat.damage");
assertEquals(2, records.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count(),
"The generic native observer cannot duplicate either owned burst hit");
assertEquals(2, damageEvents.stream().map(event -> event.getAsJsonObject("data").get("application_id").getAsString()).distinct().count());
assertTrue(damageEvents.stream().allMatch(event -> event.getAsJsonObject("data").has("target_location")
&& event.getAsJsonObject("data").get("actual_amount").getAsDouble() == 8
&& event.getAsJsonObject("data").get("damage_kind").getAsString().equals("owned_area_burst")));
var applied = abilityEvents(records, "effect.applied");
assertEquals(2, applied.stream().map(event -> event.getAsJsonObject("data").get("application_id").getAsString()).distinct().count());
assertTrue(applied.stream().allMatch(event -> event.getAsJsonObject("data").get("amplifier").getAsInt() == 1));
assertEquals(2, abilityEvents(records, "effect.ended").stream()
.filter(event -> event.getAsJsonObject("data").get("reason").getAsString().equals("expired")).count());
var outcome = abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data");
assertEquals("hit", outcome.get("outcome").getAsString());
assertEquals(2, outcome.get("accepted_count").getAsInt());
assertEquals(3, outcome.get("attempt_count").getAsInt());
assertEquals(5, outcome.get("candidate_count").getAsInt(), "Duplicate nearby references are deduplicated");
assertTrue(outcome.getAsJsonObject("burst_location").has("world_id"));
}
private static List<com.google.gson.JsonObject> events(Path root) throws Exception {
var values = new ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(root.resolve("events"))) {
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(file))
if (!line.isBlank()) values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return values;
}
private static List<com.google.gson.JsonObject> abilityEvents(List<com.google.gson.JsonObject> events, String suffix) {
return events.stream().filter(event -> event.get("type").getAsString().endsWith(suffix)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_nova")).toList();
}
@SuppressWarnings("unchecked")
@@ -274,6 +313,18 @@ class FrostNovaNativeTest {
var enemyEffects = effects(enemy); var applied = new HashMap<UUID, Double>(); damage(fixture, enemy, applied);
use(fixture, caster); drain(fixture, 2); assertEquals(12, applied.get(enemy.getUniqueId()), 1.0e-9);
assertEquals(3, enemyEffects.get(PotionEffectType.SLOWNESS).getAmplifier()); assertEquals(120L, fixture.delayedTicks.getLast());
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());
fixture.command.execute(sender.port, "fstickadmin",
new String[]{"config", "frost-nova-radius-blocks", "11"});
var failed = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(failed); failed.run();
}
assertEquals("10", ((games.dmg.fantasystick.persistence.FrostNovaSettings)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "frostNovaSettings"))
.value("frost-nova-radius-blocks"));
assertTrue(sender.messages.getLast().contains("save failed"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-nova-radius-blocks", "0"});
assertTrue(sender.messages.getLast().contains("invalid"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-nova-radius-blocks", "10", "extra"});
@@ -286,6 +337,12 @@ class FrostNovaNativeTest {
assertEquals(java.time.Duration.ofSeconds(3), saved.cooldown());
assertEquals(0, new BigDecimal("6").compareTo(saved.parameters().get("slowness-duration-seconds")));
}
var config = events(data).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().startsWith("frost-nova-")).toList();
assertEquals(6, config.size(), "One authorized read and five acknowledged saves, no denied/invalid successes");
assertEquals("8", config.getFirst().getAsJsonObject("data").get("after").getAsString());
for (String invalid : List.of("frost-nova-base-damage: -1\n", "frost-nova-cooldown-seconds: -1\n",
"frost-nova-radius-blocks: 0\n", "frost-nova-slowness-amplifier: -1\n", "frost-nova-slowness-duration-seconds: 0\n")) {
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
@@ -68,6 +68,65 @@ class GuardianBondNativeTest {
var expired = hit(enemy, ally.player, 10); fixture.dispatcher.accept(expired);
assertEquals(10, expired.getDamage(), 1.0e-9);
}
var records = events(data);
for (var type : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved",
"effect.applied", "effect.ended", "combat.prevented", "combat.damage"))
assertEquals(1, records.stream().filter(event -> event.get("type").getAsString().endsWith(type)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("guardian_bond")).count(), type);
var cast = records.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("guardian_bond")).findFirst().orElseThrow();
var prevented = records.stream().filter(event -> event.get("type").getAsString().endsWith("combat.prevented")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("guardian_bond")).findFirst().orElseThrow();
assertEquals("3", prevented.getAsJsonObject("data").get("actual_amount").getAsString());
assertEquals(cast.getAsJsonObject("data").get("action_id").getAsString(),
prevented.getAsJsonObject("data").get("action_id").getAsString());
}
private static List<com.google.gson.JsonObject> events(Path root) throws Exception {
var values = new ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(root.resolve("events"))) {
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(file))
if (!line.isBlank()) values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return values;
}
@Test void laterCancelledOriginalHitCannotClaimAcceptedPrevention() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var guardian = new FantasyStickLifecycleNativeTest.Actor(data);
var ally = new FantasyStickLifecycleNativeTest.Actor(data);
prepareGuardian(data, guardian, true);
fixture.players.put(guardian.core.id, guardian.player);
fixture.players.put(ally.core.id, ally.player);
fixture.activate(guardian); fixture.activate(ally);
party(fixture, guardian, ally); aim(guardian, ally, 8, true);
nativeDamage(fixture, guardian);
class Veto implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
public void cancel(EntityDamageByEntityEvent event) {
if (event.getEntity() == ally.player) event.setCancelled(true);
}
}
fixture.dispatcher.register(new Veto(), fixture.plugin);
use(fixture, guardian); drain(fixture, 2);
var enemy = mock(org.bukkit.entity.Zombie.class);
when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
var original = hit(enemy, ally.player, 10); fixture.dispatcher.accept(original);
assertTrue(original.isCancelled());
assertEquals(7, guardian.player.getHealth(), 1.0e-9);
}
var observed = events(data);
assertEquals(0, observed.stream().filter(event -> event.get("type").getAsString().endsWith("combat.prevented")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("guardian_bond")).count());
assertEquals(1, observed.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("guardian_bond")).count(),
"The guardian's separately accepted native damage remains truthful");
}
@Test void lethalRedirectUsesNormalDefeatAndOnlyRemovesGuardianAvailableHealth() throws Exception {
@@ -92,7 +151,10 @@ class GuardianBondNativeTest {
}
};
use(fixture, guardian); drain(fixture, 2); guardian.core.health = 1; ally.core.health = 9;
var hit = hit(mock(org.bukkit.entity.Zombie.class), ally.player, 10); fixture.dispatcher.accept(hit);
var attacker = mock(org.bukkit.entity.Zombie.class);
when(attacker.getUniqueId()).thenReturn(UUID.randomUUID());
when(attacker.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
var hit = hit(attacker, ally.player, 10); fixture.dispatcher.accept(hit);
assertEquals(List.of("guardian"), defeatOrder,
"Redirected damage reaches normal guardian defeat before original damage resumes");
assertEquals(9, hit.getDamage(), 1.0e-9, "Overkill cannot delete damage the guardian could not accept");
@@ -107,6 +169,11 @@ class GuardianBondNativeTest {
}
assertEquals(List.of("guardian", "ally"), defeatOrder, "Both characters may be defeated in the approved order");
}
var ownerDamage = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("guardian_bond")).toList();
assertEquals(1, ownerDamage.size(), "A lethal redirect still has one accepted guardian damage record");
assertEquals(1.0, ownerDamage.getFirst().getAsJsonObject("data").get("actual_amount").getAsDouble());
}
@Test void actualProtectionCreatesOnePersistedSupportShareForTheAidedDirectContributor() throws Exception {
@@ -132,6 +199,25 @@ class GuardianBondNativeTest {
}
}
@Test void cancelledOriginalNativeHitCannotCreateEffectiveSupport() {
var context = new Context(true);
when(context.guardian.getHealth()).thenReturn(10.0);
doAnswer(call -> {
var redirected = hit(call.getArgument(1), context.guardian, (Double) call.getArgument(0));
context.bond.acceptedRedirect(redirected);
return null;
}).when(context.guardian).damage(anyDouble(), any(Entity.class));
context.activate();
var cancelled = hit(context.enemy, context.ally, 10);
context.bond.redirect(cancelled); cancelled.setCancelled(true);
context.bond.acceptedPrevention(cancelled);
assertTrue(context.supports.isEmpty(), "A later-vetoed original hit cannot create support credit");
var accepted = hit(context.enemy, context.ally, 10);
context.bond.redirect(accepted); context.bond.acceptedPrevention(accepted);
assertEquals(1, context.supports.size());
assertEquals(0, new BigDecimal("3").compareTo(context.supports.getFirst().amount()));
}
@Test void conservedRoutingUsesPostRecipientAndPostGuardianMitigationCancellationAndLethalCaps() {
var context = new Context(); var requested = new ArrayList<BigDecimal>();
var mitigation = new games.dmg.fantasystick.ability.CombatMitigation();
@@ -268,6 +354,19 @@ class GuardianBondNativeTest {
nativeDamage(fixture, guardian); use(fixture, guardian); drain(fixture, 2);
var future = hit(mock(org.bukkit.entity.Zombie.class), ally.player, 10); fixture.dispatcher.accept(future);
assertEquals(5, future.getDamage(), 1.0e-9); assertEquals(180L, fixture.delayedTicks.getLast());
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());
fixture.command.execute(sender.port, "fstickadmin",
new String[]{"config", "guardian-bond-range-blocks", "17"});
var failed = fixture.tasks.poll(5, TimeUnit.SECONDS);
assertNotNull(failed); failed.run();
}
assertEquals("16", ((games.dmg.fantasystick.persistence.GuardianBondSettings)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "guardianBondSettings"))
.value("guardian-bond-range-blocks"));
assertTrue(sender.messages.getLast().contains("save failed"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "guardian-bond-duration-seconds", "0"});
assertTrue(sender.messages.getLast().contains("invalid"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "guardian-bond-range-blocks", "16", "extra"});
@@ -281,6 +380,18 @@ class GuardianBondNativeTest {
assertEquals(0, new BigDecimal("9").compareTo(afterRestart.parameters().get("duration-seconds")));
assertEquals(new BigDecimal("50"), afterRestart.parameters().get("redirect-percent"));
}
var config = events(data).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().startsWith("guardian-bond-")).toList();
assertEquals(5, config.size());
assertEquals("14", config.getFirst().getAsJsonObject("data").get("after").getAsString());
assertEquals("console", config.getFirst().getAsJsonObject("data").get("actor_kind").getAsString());
var range = config.stream().filter(event -> event.get("type").getAsString().endsWith("config.changed")
&& event.getAsJsonObject("data").get("key").getAsString().equals("guardian-bond-range-blocks"))
.findFirst().orElseThrow();
assertEquals("14", range.getAsJsonObject("data").get("before").getAsString());
assertEquals("16", range.getAsJsonObject("data").get("after").getAsString());
for (String invalid : List.of("guardian-bond-cooldown-seconds: -1\n", "guardian-bond-range-blocks: 0\n",
"guardian-bond-duration-seconds: 0\n", "guardian-bond-redirect-percent: 101\n")) {
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
@@ -299,7 +410,8 @@ class GuardianBondNativeTest {
GuardianBond.Damage damage = (target, attacker, amount) -> amount; final GuardianBond bond;
final EquipmentSessions.Active guardianActive; final EquipmentSessions.Active allyActive;
final PartyRoster.Party party;
Context() {
Context() { this(false); }
Context(boolean nativeAdapter) {
var guardianId = UUID.randomUUID(); var allyId = UUID.randomUUID();
when(guardian.getUniqueId()).thenReturn(guardianId); when(ally.getUniqueId()).thenReturn(allyId);
when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
@@ -314,8 +426,10 @@ class GuardianBondNativeTest {
when(parties.partyOf(guardianId)).thenReturn(java.util.Optional.of(party)); when(parties.partyOf(allyId)).thenReturn(java.util.Optional.of(party));
when(input.player()).thenReturn(guardian); when(input.target()).thenReturn(ally); when(input.session()).thenReturn(guardianActive);
when(execution.input()).thenReturn(input); when(execution.stillCurrent()).thenReturn(true);
bond = new GuardianBond(GuardianBond.Settings.defaults(), sessions, parties,
(target, attacker, amount) -> damage.apply(target, attacker, amount), supports::add);
bond = nativeAdapter
? new GuardianBond(GuardianBond.Settings.defaults(), sessions, parties, supports::add)
: new GuardianBond(GuardianBond.Settings.defaults(), sessions, parties,
(target, attacker, amount) -> damage.apply(target, attacker, amount), supports::add);
when(execution.definition()).thenReturn(bond.definition());
}
void activate() { bond.activate(execution); }
@@ -88,6 +88,45 @@ class MendingLightNativeTest {
resumed.core.inventory.setItemInOffHand(allySheet); fixture.players.put(resumed.core.id, resumed.player); fixture.activate(resumed);
assertEquals(10, resumed.player.getHealth(), "Healed health survives exit, transfer and resume");
}
var records = events(data);
for (var type : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved", "combat.healed"))
assertEquals(type.equals("ability.learned") || type.equals("ability.assigned") ? 2 : 1,
records.stream().filter(event -> event.get("type").getAsString().endsWith(type)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("mending_light")).count(), type);
var cast = records.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("mending_light")).findFirst().orElseThrow();
var healed = records.stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("mending_light")).findFirst().orElseThrow();
assertEquals(cast.getAsJsonObject("data").get("action_id").getAsString(),
healed.getAsJsonObject("data").get("action_id").getAsString());
assertEquals("6", healed.getAsJsonObject("data").get("actual_amount").getAsString());
assertEquals("4", healed.getAsJsonObject("data").get("health_before").getAsString());
assertEquals("10", healed.getAsJsonObject("data").get("health_after").getAsString());
}
@Test void accountFeedbackOptOutCannotHideAnAcceptedNativeHealEvent() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var caster = new FantasyStickLifecycleNativeTest.Actor(data);
var ally = new FantasyStickLifecycleNativeTest.Actor(data);
prepareCaster(data, caster, true); setHealth(data, ally, BigDecimal.valueOf(4));
fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally, 8, true);
assertTrue(fixture.playerCommand.getExecutor().onCommand(caster.player, fixture.playerCommand,
"fstick", new String[]{"feedback", "off"}));
fixture.tick();
use(fixture, caster); drain(fixture, 3);
assertFalse(caster.messages.contains("Mending Light healed " + ally.core.record.name() + " for 6 health."));
assertTrue(ally.messages.contains("Mending Light healed " + ally.core.record.name() + " for 6 health."));
fixture.plugin.setEnabled(false);
}
var records = events(data);
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("combat.healed")
&& e.getAsJsonObject("data").has("ability_id")
&& e.getAsJsonObject("data").get("ability_id").getAsString().equals("mending_light")).count());
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).count());
}
@Test void actualHealingCreatesOnePersistedSupportShareForAnAllyDirectContributor() throws Exception {
@@ -124,6 +163,16 @@ class MendingLightNativeTest {
}
}
private static List<com.google.gson.JsonObject> events(Path root) throws Exception {
var values = new ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(root.resolve("events"))) {
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(file))
if (!line.isBlank()) values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return values;
}
@Test void selfOrdinaryNonPartyFullHealthRangeAndLineOfSightRefuseBeforeCooldown() throws Exception {
for (int route = 0; route < 6; route++) {
var directory = data.resolve("refusal-" + route);
@@ -145,6 +194,9 @@ class MendingLightNativeTest {
assertFalse(storage(fixture).find(caster.core.record.id()).get().orElseThrow()
.cooldowns().containsKey("mending_light"), "route " + route + " must refuse before cooldown");
}
assertEquals(0, events(directory).stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("mending_light")).count());
}
}
@@ -166,6 +218,14 @@ class MendingLightNativeTest {
assertEquals(4, ally.player.getHealth());
assertNotNull(storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("mending_light"));
}
var observed = events(data);
assertEquals(0, observed.stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("mending_light")).count());
assertEquals(1, observed.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("mending_light")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("cancelled")).count());
}
@Test void targetSaveFailureOrSessionReplacementPublishesNoNativeHealingOrSupport() {
@@ -211,6 +271,8 @@ class MendingLightNativeTest {
verify(target, never()).setHealth(anyDouble()); assertTrue(supports.isEmpty());
verify(sessions, never()).acknowledgeProgress(eq(targetActive), any(), any());
verify(execution, times(2)).finish();
verify(execution, never()).acceptedHeal(any(), any(), any(), anyDouble(), anyDouble(),
anyDouble(), anyDouble(), anyDouble());
}
@Test void configuredDefinitionAdminRestartAndFutureCastCoverEverySetting() throws Exception {
@@ -243,6 +305,19 @@ class MendingLightNativeTest {
fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally, 15, true);
use(fixture, caster); drain(fixture, 3); assertEquals(10, ally.player.getHealth());
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());
fixture.command.execute(sender.port, "fstickadmin",
new String[]{"config", "mending-light-base-healing", "10"});
var failed = fixture.tasks.poll(5, TimeUnit.SECONDS);
assertNotNull(failed); failed.run();
}
assertEquals("9", ((MendingLightSettings)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "mendingLightSettings"))
.value("mending-light-base-healing"));
assertTrue(sender.messages.getLast().contains("save failed"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "mending-light-base-healing", "-1"});
assertTrue(sender.messages.getLast().contains("invalid"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "mending-light-base-healing", "9", "extra"});
@@ -252,6 +327,15 @@ class MendingLightNativeTest {
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
assertEquals(new BigDecimal("9"), reloaded.find("mending_light").orElseThrow().parameters().get("base-healing"));
}
var config = events(data).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("mending-light-base-healing")).toList();
assertEquals(2, config.size());
assertEquals("8", config.getFirst().getAsJsonObject("data").get("after").getAsString());
assertEquals("8", config.getLast().getAsJsonObject("data").get("before").getAsString());
assertEquals("9", config.getLast().getAsJsonObject("data").get("after").getAsString());
assertEquals("console", config.getLast().getAsJsonObject("data").get("actor_kind").getAsString());
for (String invalid : List.of("mending-light-base-healing: -1\n", "mending-light-cooldown-seconds: -1\n",
"mending-light-range-blocks: 0\n")) {
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
@@ -29,12 +29,55 @@ class MeteorNativeTest {
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void characterBookPurchaseEquippedGroundWarningAndNativeImpact()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false);f.players.put(caster.core.id,caster.player);f.activate(caster);var placement=placement(caster,24,BlockFace.UP);var enemy=mock(LivingEntity.class);locate(enemy,caster.world,new UUID(0,2),2,65,0);doReturn(List.of(enemy)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());var damaged=new ArrayList<Double>();doAnswer(call->{double amount=call.getArgument(0);damaged.add(amount);var source=(DamageSource)call.getArgument(1);f.dispatcher.accept(new EntityDamageByEntityEvent(caster.player,enemy,EntityDamageEvent.DamageCause.MAGIC,source,amount));return null;}).when(enemy).damage(anyDouble(),any(DamageSource.class));
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false);f.players.put(caster.core.id,caster.player);f.activate(caster);var placement=placement(caster,24,BlockFace.UP);var enemy=mock(LivingEntity.class);locate(enemy,caster.world,new UUID(0,2),2,65,0);
var vetoed=mock(LivingEntity.class);locate(vetoed,caster.world,new UUID(0,3),3,65,0);
doReturn(List.of(enemy,vetoed)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());
class Veto implements org.bukkit.event.Listener{
@org.bukkit.event.EventHandler(priority=org.bukkit.event.EventPriority.HIGHEST)
public void cancel(EntityDamageEvent event){if(event.getEntity()==vetoed)event.setCancelled(true);}
}
f.dispatcher.register(new Veto(),f.plugin);
doAnswer(call->{var source=(DamageSource)call.getArgument(1);
f.dispatcher.accept(new EntityDamageByEntityEvent(caster.player,vetoed,EntityDamageEvent.DamageCause.MAGIC,
source,(double)call.getArgument(0)));return null;
}).when(vetoed).damage(anyDouble(),any(DamageSource.class));
var damaged=new ArrayList<Double>();doAnswer(call->{double amount=call.getArgument(0);damaged.add(amount);var source=(DamageSource)call.getArgument(1);f.dispatcher.accept(new EntityDamageByEntityEvent(caster.player,enemy,EntityDamageEvent.DamageCause.MAGIC,source,amount));return null;}).when(enemy).damage(anyDouble(),any(DamageSource.class));
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Meteor"));assertTrue(text.contains("INT26"));assertTrue(text.contains("WIS20"));assertTrue(text.contains("CON12"));assertTrue(text.contains("2-secondwarning"));PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","meteor"));drain(f,2);var viewer=new FantasyStickLifecycleNativeTest.Actor(data);viewer.core.inventory.setItemInOffHand(null);viewer.core.location.setWorld(caster.world);f.players.put(viewer.core.id,viewer.player);PartyBookNativeTest.disableAllParticles(f,caster);caster.particleCalls.clear();viewer.particleCalls.clear();use(f,caster);drain(f,2);assertTrue(damaged.isEmpty());assertEquals(1L,f.delayedTicks.getLast());for(int i=0;i<39;i++)f.delayed.removeFirst().run();assertTrue(damaged.isEmpty(),"The 40-tick warning must remain escapable");f.delayed.removeFirst().run();assertEquals(List.of(22d),damaged);assertTrue(caster.particleCalls.isEmpty(),"The opted-out caster receives no Meteor warning or impact particles");assertTrue(viewer.particleCalls.stream().filter(call->call[0]==Particle.DUST&&call[2].equals(24)).count()>=40);assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.EXPLOSION_EMITTER&&call[2].equals(2)));assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.FLAME&&call[2].equals(48)));verify(caster.world,atLeastOnce()).playSound(any(Location.class),eq(Sound.ENTITY_GENERIC_EXPLODE),eq(1f),eq(.7f));verify(placement,never()).setType(any(Material.class));assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("meteor"));assertEquals(BigInteger.ZERO,storage(f).find(caster.core.record.id()).get().orElseThrow().unspentPoints());
}
var records=events(data);
for(var type:List.of("ability.learned","ability.assigned","ability.cast","ability.resolved","combat.damage"))
assertEquals(1,abilityEvents(records,type).size(),type);
assertEquals(1,records.stream().filter(event->event.get("type").getAsString().endsWith("combat.damage")).count(),
"The generic observer must not duplicate Meteor-owned native damage");
var impact=abilityEvents(records,"combat.damage").getFirst().getAsJsonObject("data");
assertEquals(22,impact.get("actual_amount").getAsDouble(),1e-9);
assertTrue(impact.has("target_location"));
var outcome=abilityEvents(records,"ability.resolved").getFirst().getAsJsonObject("data");
assertEquals("hit",outcome.get("outcome").getAsString());
assertEquals(1,outcome.get("accepted_count").getAsInt());
assertEquals(2,outcome.get("attempted_count").getAsInt(),"Cancelled native impact has no damage success");
assertTrue(outcome.getAsJsonObject("impact_location").has("world_id"));
}
@Test void missingOrSideGroundRefusesBeforeCooldown()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true);f.players.put(caster.core.id,caster.player);f.activate(caster);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));placement(caster,24,BlockFace.NORTH);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));placement(caster,24,BlockFace.UP);use(f,caster);drain(f,2);assertTrue(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));}}
private static List<com.google.gson.JsonObject> events(Path root)throws Exception{
var values=new ArrayList<com.google.gson.JsonObject>();
try(var files=java.nio.file.Files.walk(root.resolve("events"))){
for(var file:files.filter(path->path.toString().endsWith(".jsonl")).toList())
for(var line:java.nio.file.Files.readAllLines(file))
if(!line.isBlank())values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return values;
}
private static List<com.google.gson.JsonObject> abilityEvents(List<com.google.gson.JsonObject> events,String suffix){
return events.stream().filter(event->event.get("type").getAsString().endsWith(suffix)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("meteor")).toList();
}
@Test void missingOrSideGroundRefusesBeforeCooldown()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true);f.players.put(caster.core.id,caster.player);f.activate(caster);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));placement(caster,24,BlockFace.NORTH);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));placement(caster,24,BlockFace.UP);use(f,caster);drain(f,2);assertTrue(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));}
assertEquals(1,abilityEvents(events(data),"ability.cast").size(),"Only real ground selection commits a cooldown");
assertTrue(abilityEvents(events(data),"combat.damage").isEmpty(),"Warning alone cannot claim a hit");
}
@Test void impactRechecksGroundPartyModeGeometryObstructionCancellationAndSourceLifecycle(){
var d=direct();var escaping=target(d.world,new UUID(0,1),2,65);var joined=mock(Player.class);locate(joined,d.world,new UUID(0,2),2,65,1);var ordinary=mock(Player.class);locate(ordinary,d.world,new UUID(0,3),2,65,2);var inactive=mock(Player.class);locate(inactive,d.world,new UUID(0,8),2,65,3);var inactiveSession=new EquipmentSessions.Active(inactive.getUniqueId(),UUID.randomUUID(),UUID.randomUUID());when(d.sessions.active(inactive.getUniqueId())).thenReturn(Optional.of(inactiveSession));when(d.sessions.current(inactiveSession)).thenReturn(false);var blocked=target(d.world,new UUID(0,4),4.5,65);var cancelled=target(d.world,new UUID(0,5),2,65);var boundary=mock(LivingEntity.class);locate(boundary,d.world,new UUID(0,6),5.5,69,.5);var outside=mock(LivingEntity.class);locate(outside,d.world,new UUID(0,7),5.51,69,.5);doReturn(List.of(escaping,joined,ordinary,inactive,blocked,cancelled,boundary,outside,d.caster)).when(d.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());var joinedActive=new EquipmentSessions.Active(joined.getUniqueId(),UUID.randomUUID(),UUID.randomUUID());when(d.sessions.active(joined.getUniqueId())).thenReturn(Optional.of(joinedActive));when(d.sessions.current(joinedActive)).thenReturn(true);var party=new PartyRoster.Party(UUID.randomUUID(),d.caster.getUniqueId(),List.of(d.caster.getUniqueId(),joined.getUniqueId()));when(d.parties.partyOf(d.caster.getUniqueId())).thenReturn(Optional.of(party));when(d.parties.partyOf(joined.getUniqueId())).thenReturn(Optional.of(party));doReturn(mock(RayTraceResult.class)).when(d.world).rayTraceBlocks(any(Location.class),any(org.bukkit.util.Vector.class),doubleThat(distance->distance>4&&distance<4.5),eq(FluidCollisionMode.NEVER),eq(true));when(escaping.getLocation()).thenReturn(new Location(d.world,6,65,.5));doAnswer(call->{var event=new EntityDamageEvent(cancelled,EntityDamageEvent.DamageCause.MAGIC,(DamageSource)call.getArgument(1),(double)call.getArgument(0));event.setCancelled(true);d.meteor.acceptedDamage(event);return null;}).when(cancelled).damage(anyDouble(),any(DamageSource.class));doAnswer(call->{d.meteor.acceptedDamage(new EntityDamageEvent(boundary,EntityDamageEvent.DamageCause.MAGIC,(DamageSource)call.getArgument(1),(double)call.getArgument(0)));return null;}).when(boundary).damage(anyDouble(),any(DamageSource.class));d.meteor.activate(d.execution);run(d.scheduled,40);verify(escaping,never()).damage(anyDouble(),any(DamageSource.class));verify(joined,never()).damage(anyDouble(),any(DamageSource.class));verify(ordinary,never()).damage(anyDouble(),any(DamageSource.class));verify(inactive,never()).damage(anyDouble(),any(DamageSource.class));verify(blocked,never()).damage(anyDouble(),any(DamageSource.class));verify(cancelled).damage(anyDouble(),any(DamageSource.class));verify(boundary).damage(anyDouble(),any(DamageSource.class));verify(outside,never()).damage(anyDouble(),any(DamageSource.class));verify(d.execution,times(1)).report(eq(AbilityReport.Type.DAMAGE),eq(boundary),any());
@@ -44,9 +87,27 @@ class MeteorNativeTest {
@Test void impactUsesExactCylinderDistanceUuidOrderAndSharedBoundsOnce(){var d=direct();var targets=new ArrayList<LivingEntity>();var order=new ArrayList<UUID>();for(int i=0;i<257;i++){var target=target(d.world,new UUID(0,i+1),i==256?0:Math.min(5,i/20d),65+(i%2==0?4:0));targets.add(target);doAnswer(call->{order.add(target.getUniqueId());d.meteor.acceptedDamage(new EntityDamageEvent(target,EntityDamageEvent.DamageCause.MAGIC,(DamageSource)call.getArgument(1),(double)call.getArgument(0)));return null;}).when(target).damage(anyDouble(),any(DamageSource.class));}doReturn(new ArrayList<Entity>(targets)).when(d.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());d.meteor.activate(d.execution);run(d.scheduled,40);assertEquals(64,order.size());assertFalse(order.contains(targets.get(256).getUniqueId()),"Candidates after the 256 inspection ceiling are ignored");assertEquals(new UUID(0,11),order.getFirst());verify(d.execution,times(64)).report(eq(AbilityReport.Type.DAMAGE),any(),any());assertEquals(BigInteger.valueOf(22),Meteor.damage(BigInteger.valueOf(26)));}
@Test void configuredDefinitionAdminRestartAndFutureImpactCoverEverySetting()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"meteor-base-damage: 16\nmeteor-cooldown-seconds: 3\nmeteor-target-range-blocks: 20\nmeteor-warning-seconds: 1\nmeteor-radius-blocks: 6\nmeteor-vertical-range-blocks: 3\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var registry=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");var definition=registry.find("meteor").orElseThrow();assertEquals(new BigDecimal("16"),definition.parameters().get("base-damage"));assertEquals(java.time.Duration.ofSeconds(3),definition.cooldown());var sender=new EquipmentAdminCommandNativeTest.Sender();sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","meteor-"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"7"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;assertEquals(6,f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","meteor-"}).size());assertEquals(List.of("5","6"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,""}));for(String[] update:List.of(new String[]{MeteorSettings.BASE_DAMAGE,"18"},new String[]{MeteorSettings.COOLDOWN,"4"},new String[]{MeteorSettings.TARGET_RANGE,"22"},new String[]{MeteorSettings.WARNING,"1.5"},new String[]{MeteorSettings.RADIUS,"7"},new String[]{MeteorSettings.VERTICAL,"5"})){f.command.execute(sender.port,"fantasystick",new String[]{"config",update[0],update[1]});var response=f.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(response);response.run();}var configured=registry.find("meteor").orElseThrow();assertEquals(new BigDecimal("18"),configured.parameters().get("base-damage"));assertEquals(0,new BigDecimal("1.5").compareTo(configured.parameters().get("warning-seconds")));
@Test void configuredDefinitionAdminRestartAndFutureImpactCoverEverySetting()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"meteor-base-damage: 16\nmeteor-cooldown-seconds: 3\nmeteor-target-range-blocks: 20\nmeteor-warning-seconds: 1\nmeteor-radius-blocks: 6\nmeteor-vertical-range-blocks: 3\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var registry=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");var definition=registry.find("meteor").orElseThrow();assertEquals(new BigDecimal("16"),definition.parameters().get("base-damage"));assertEquals(java.time.Duration.ofSeconds(3),definition.cooldown());var sender=new EquipmentAdminCommandNativeTest.Sender();sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","meteor-"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"7"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;assertEquals(6,f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","meteor-"}).size());assertEquals(List.of("5","6"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,""}));
f.command.execute(sender.port,"fstickadmin",new String[]{"config",MeteorSettings.RADIUS});
assertTrue(sender.messages.getLast().contains("=6"));
for(String[] update:List.of(new String[]{MeteorSettings.BASE_DAMAGE,"18"},new String[]{MeteorSettings.COOLDOWN,"4"},new String[]{MeteorSettings.TARGET_RANGE,"22"},new String[]{MeteorSettings.WARNING,"1.5"},new String[]{MeteorSettings.RADIUS,"7"},new String[]{MeteorSettings.VERTICAL,"5"})){f.command.execute(sender.port,"fantasystick",new String[]{"config",update[0],update[1]});var response=f.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(response);response.run();}var configured=registry.find("meteor").orElseThrow();assertEquals(new BigDecimal("18"),configured.parameters().get("base-damage"));assertEquals(0,new BigDecimal("1.5").compareTo(configured.parameters().get("warning-seconds")));
var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true);f.players.put(caster.core.id,caster.player);f.activate(caster);placement(caster,22,BlockFace.UP);var enemy=mock(LivingEntity.class);locate(enemy,caster.world,UUID.randomUUID(),2,65,0);doReturn(List.of(enemy)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());var damaged=new ArrayList<Double>();doAnswer(call->{damaged.add(call.getArgument(0));return null;}).when(enemy).damage(anyDouble(),any(DamageSource.class));use(f,caster);drain(f,2);for(int i=0;i<30;i++)f.delayed.removeFirst().run();assertEquals(List.of(26d),damaged);
f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"0"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"7","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reloaded=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new BigDecimal("18"),reloaded.find("meteor").orElseThrow().parameters().get("base-damage"));}}
try(var channel=java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
java.nio.file.StandardOpenOption.CREATE,java.nio.file.StandardOpenOption.WRITE);
var lock=channel.lock()){
assertTrue(lock.isValid());
f.command.execute(sender.port,"fstickadmin",new String[]{"config",MeteorSettings.RADIUS,"8"});
var failed=f.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(failed);failed.run();
}
assertEquals("7",((MeteorSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"meteorSettings")).value(MeteorSettings.RADIUS));
assertTrue(sender.messages.getLast().contains("save failed"));
f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"0"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"7","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reloaded=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new BigDecimal("18"),reloaded.find("meteor").orElseThrow().parameters().get("base-damage"));}
var config=events(data).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().startsWith("meteor-")).toList();
assertEquals(7,config.size(),"Authorized read and six acknowledged saved updates only");
}
private record Direct(Meteor meteor,AbilityExecution execution,Player caster,World world,EquipmentSessions sessions,PartyRoster parties,ArrayDeque<Runnable> scheduled,java.util.concurrent.atomic.AtomicReference<Material> blockType){}
private static Direct direct(){var sessions=mock(EquipmentSessions.class);var parties=mock(PartyRoster.class);var caster=mock(Player.class);var world=mock(World.class);UUID id=UUID.randomUUID();when(caster.getUniqueId()).thenReturn(id);when(caster.getWorld()).thenReturn(world);when(caster.getLocation()).thenReturn(new Location(world,0,65,0));when(caster.getBoundingBox()).thenReturn(new BoundingBox(0,65,0,1,67,1));var active=new EquipmentSessions.Active(id,UUID.randomUUID(),UUID.randomUUID());when(sessions.active(id)).thenReturn(Optional.of(active));when(sessions.current(active)).thenReturn(true);var type=new java.util.concurrent.atomic.AtomicReference<>(Material.STONE);var block=mock(Block.class);when(block.getType()).thenAnswer(call->type.get());when(block.getWorld()).thenReturn(world);when(block.getLocation()).thenReturn(new Location(world,0,64,0));var ray=mock(RayTraceResult.class);when(ray.getHitBlock()).thenReturn(block);when(ray.getHitBlockFace()).thenReturn(BlockFace.UP);when(caster.getEyeLocation()).thenReturn(new Location(world,0,65.62,0,0,45));doReturn(ray).when(world).rayTraceBlocks(any(Location.class),any(org.bukkit.util.Vector.class),eq(24d),eq(FluidCollisionMode.NEVER),eq(true));var execution=mock(AbilityExecution.class);var input=mock(ControlInput.class);when(input.player()).thenReturn(caster);when(input.session()).thenReturn(active);when(execution.input()).thenReturn(input);when(execution.stillCurrent()).thenReturn(true);doAnswer(call->{((LivingEntity)call.getArgument(0)).damage((Double)call.getArgument(1),(org.bukkit.damage.DamageSource)call.getArgument(2));return null;}).when(execution).damage(any(LivingEntity.class),anyDouble(),any(org.bukkit.damage.DamageSource.class));var character=mock(CharacterRecord.class);var stats=new EnumMap<Stat,BigInteger>(Stat.class);for(var stat:Stat.values())stats.put(stat,BigInteger.ZERO);stats.put(Stat.INT,BigInteger.valueOf(26));when(character.stats()).thenReturn(stats);when(execution.character()).thenReturn(character);var scheduled=new ArrayDeque<Runnable>();doAnswer(call->{scheduled.addLast(call.getArgument(1));return mock(AbilityScheduler.Task.class);}).when(execution).later(any(),any());var meteor=new Meteor(Meteor.Settings.defaults(),sessions,parties);when(execution.definition()).thenReturn(meteor.definition());return new Direct(meteor,execution,caster,world,sessions,parties,scheduled,type);}
@@ -116,16 +116,18 @@ class OutOfCombatRecoveryNativeTest {
stale.clock.advance(Duration.ofSeconds(9)); stale.recovery.sweep(); assertEquals(1, stale.events.size());
}
@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 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();
assertEquals(5, zero.health.get(), "A zero passive bonus leaves the base pulse unchanged");
var cancelled = new Direct(); cancelled.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3)); cancelled.veto = true;
var cancelled = new Direct(); cancelled.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3));var audit=new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();cancelled.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(audit::add,cancelled.clock),ignored->BigInteger.valueOf(3));cancelled.veto = true;
var cancelledRevision = cancelled.record.get().revision();
cancelled.recovery.sweep(); cancelled.clock.advance(Duration.ofSeconds(10)); cancelled.recovery.sweep();
assertEquals(4, cancelled.events.getFirst().getAmount());
assertEquals(4, cancelled.health.get()); assertEquals(cancelledRevision, cancelled.record.get().revision());
assertEquals(4, cancelled.health.get()); assertEquals(cancelledRevision, cancelled.record.get().revision());assertEquals(1,audit.stream().filter(event->event.type().equals("action.observed")).count());assertEquals(1,audit.stream().filter(event->event.type().equals("action.resolved")&&event.data().get("outcome").equals("refused")&&event.data().get("reason").equals("native_event_cancelled")).count());assertEquals(0,audit.stream().filter(event->event.type().equals("combat.healed")).count());
var full = new Direct(); full.health.set(10d); full.record.set(full.saved(BigDecimal.TEN));
full.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3)); var fullRevision = full.record.get().revision();
@@ -241,7 +243,7 @@ class OutOfCombatRecoveryNativeTest {
base.unspentPoints(), BigDecimal.valueOf(4), base.learned(), base.loadout(), base.cooldowns(),
base.restrictedKills(), false, null, null));
active = new EquipmentSessions.Active(java.util.UUID.randomUUID(), java.util.UUID.randomUUID(), base.id());
when(player.getUniqueId()).thenReturn(active.player()); when(player.getHealth()).thenAnswer(call -> health.get());
var world=mock(org.bukkit.World.class);when(world.getUID()).thenReturn(java.util.UUID.randomUUID());when(world.getName()).thenReturn("world");when(player.getUniqueId()).thenReturn(active.player());when(player.getName()).thenReturn("Player");when(player.getLocation()).thenReturn(new org.bukkit.Location(world,0,64,0)); when(player.getHealth()).thenAnswer(call -> health.get());
doAnswer(call -> { health.set(call.getArgument(0)); return null; }).when(player).setHealth(anyDouble());
when(player.getAttribute(Attribute.MAX_HEALTH)).thenReturn(maximum); when(maximum.getValue()).thenReturn(10d);
when(sessions.active(active.player())).thenReturn(Optional.of(active));
@@ -43,21 +43,25 @@ class PartyRegistrarNativeTest {
@SuppressWarnings("unchecked")
@Test void adminPlacementReconcilesProtectedVillagerAndActiveCharacterCanPayToRegister()throws Exception{
UUID invitedPlayer,invitedCharacter,invitedSession;
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);
doReturn(true).when(actor.player).hasPermission("fantasystick.admin");UUID worldId=UUID.randomUUID();when(actor.world.getUID()).thenReturn(worldId);
int chunkX=actor.core.location.getBlockX()>>4,chunkZ=actor.core.location.getBlockZ()>>4;var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);when(chunk.getX()).thenReturn(chunkX);when(chunk.getZ()).thenReturn(chunkZ);when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[0]);when(actor.world.getChunkAt(chunkX,chunkZ)).thenReturn(chunk);when(actor.world.getChunkAt(any(Location.class))).thenReturn(chunk);when(actor.world.isChunkLoaded(chunkX,chunkZ)).thenReturn(true);when(actor.world.getEntities()).thenReturn(List.of());
var villager=mock(Villager.class);when(villager.getLocation()).thenReturn(actor.core.location);var values=new HashMap<String,String>();var pdc=mock(PersistentDataContainer.class);when(villager.getPersistentDataContainer()).thenReturn(pdc);doAnswer(call->{values.put(((NamespacedKey)call.getArgument(0)).getKey(),call.getArgument(2));return null;}).when(pdc).set(any(NamespacedKey.class),eq(PersistentDataType.STRING),anyString());when(pdc.get(any(NamespacedKey.class),eq(PersistentDataType.STRING))).thenAnswer(call->values.get(((NamespacedKey)call.getArgument(0)).getKey()));
doAnswer(call->{((java.util.function.Consumer<Villager>)call.getArgument(2)).accept(villager);return villager;}).when(actor.world).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));
int chunkX=actor.core.location.getBlockX()>>4,chunkZ=actor.core.location.getBlockZ()>>4;var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);when(chunk.getX()).thenReturn(chunkX);when(chunk.getZ()).thenReturn(chunkZ);when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[0]);when(actor.world.getChunkAt(chunkX,chunkZ)).thenReturn(chunk);when(actor.world.getChunkAt(any(Location.class))).thenReturn(chunk);when(actor.world.isChunkLoaded(chunkX,chunkZ)).thenReturn(true);var worldEntities=new ArrayList<org.bukkit.entity.Entity>();when(actor.world.getEntities()).thenAnswer(call->List.copyOf(worldEntities));
var villager=mock(Villager.class);when(villager.getUniqueId()).thenReturn(UUID.randomUUID());when(villager.getLocation()).thenReturn(actor.core.location);var values=new HashMap<String,String>();var pdc=mock(PersistentDataContainer.class);when(villager.getPersistentDataContainer()).thenReturn(pdc);doAnswer(call->{values.put(((NamespacedKey)call.getArgument(0)).getKey(),call.getArgument(2));return null;}).when(pdc).set(any(NamespacedKey.class),eq(PersistentDataType.STRING),anyString());when(pdc.get(any(NamespacedKey.class),eq(PersistentDataType.STRING))).thenAnswer(call->values.get(((NamespacedKey)call.getArgument(0)).getKey()));
doAnswer(call->{((java.util.function.Consumer<Villager>)call.getArgument(2)).accept(villager);worldEntities.add(villager);return villager;}).when(actor.world).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));
f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"party-registrar","place","Central"});var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();verify(actor.world).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));verify(villager).setAI(false);verify(villager).setInvulnerable(true);
var directory=(games.dmg.fantasystick.party.PartyRegistrarDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrars");var registrar=directory.all().getFirst();assertEquals("Central",registrar.name());
var old=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,null,null,BigInteger.valueOf(20),true,old.rewardReceipts()),old.revision());f.activate(actor);var management=(games.dmg.fantasystick.party.PartyManagement)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyManagement");management.ready().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);assertTrue(management.active(actor.player,registrar.id()).isPresent());f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"config","party-registration-price","7"});for(int attempt=0;attempt<10&&actor.messages.stream().noneMatch(message->message.contains("Saved party-registration-price=7"));attempt++){response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();}f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"config","party-leader-change-price","6"});for(int attempt=0;attempt<10&&actor.messages.stream().noneMatch(message->message.contains("Saved party-leader-change-price=6"));attempt++){response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();}var controller=(games.dmg.fantasystick.party.PartyRegistrarController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarController");assertTrue(controller.tagged(villager));
var old=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,null,null,BigInteger.valueOf(20),true,old.rewardReceipts()),old.revision());f.activate(actor);var management=(games.dmg.fantasystick.party.PartyManagement)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyManagement");management.ready().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);assertTrue(management.active(actor.player,registrar.id()).isPresent());f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"config","party-registration-price","7"});for(int attempt=0;attempt<10&&actor.messages.stream().noneMatch(message->message.contains("Saved party-registration-price=7"));attempt++){response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();}f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"config","party-leader-change-price","6"});for(int attempt=0;attempt<10&&actor.messages.stream().noneMatch(message->message.contains("Saved party-leader-change-price=6"));attempt++){response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();}f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"config","party-registration-price"});f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"config","party-leader-change-price"});var controller=(games.dmg.fantasystick.party.PartyRegistrarController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarController");assertTrue(controller.tagged(villager));
int openMenus=actor.inventories.size(),openBooks=actor.books.size();var mainInteraction=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);f.dispatcher.accept(mainInteraction);var offhandDuplicate=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.OFF_HAND);f.dispatcher.accept(offhandDuplicate);assertTrue(mainInteraction.isCancelled());assertTrue(offhandDuplicate.isCancelled());assertEquals(openMenus+1,actor.inventories.size());assertEquals(openBooks,actor.books.size());assertEquals(Material.WRITABLE_BOOK,actor.inventories.getLast().getItem(11).getType());var vetoedInteraction=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);vetoedInteraction.setCancelled(true);f.dispatcher.accept(vetoedInteraction);assertEquals(openMenus+1,actor.inventories.size());assertEquals(openBooks,actor.books.size());
var menus=(games.dmg.fantasystick.party.PartyRegistrarMenus)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarMenus");var root=actor.inventories.getLast();assertEquals(net.kyori.adventure.text.Component.text("Register a party — 7 coins"),root.getItem(11).getItemMeta().displayName());var openName=mock(org.bukkit.event.inventory.InventoryClickEvent.class);when(openName.getInventory()).thenReturn(root);when(openName.getWhoClicked()).thenReturn(actor.player);when(openName.getCurrentItem()).thenReturn(root.getItem(11));menus.click(openName);var anvil=actor.inventories.getLast();var submit=mock(org.bukkit.event.inventory.InventoryClickEvent.class);when(submit.getInventory()).thenReturn(anvil);when(submit.getWhoClicked()).thenReturn(actor.player);when(submit.getRawSlot()).thenReturn(2);var anvilView=mock(org.bukkit.inventory.view.AnvilView.class);when(anvilView.getRenameText()).thenReturn(" Cafe\u0301 Heroes ");when(submit.getView()).thenReturn(anvilView);menus.click(submit);var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");characters.find(UUID.randomUUID()).join();var parties=(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals("Café Heroes",parties.partyOf(old.id()).orElseThrow().displayName());assertEquals(BigInteger.valueOf(13),characters.find(old.id()).join().orElseThrow().coins());
var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(actor.world);f.players.put(member.core.id,member.player);f.activate(member);var leaderRoot=actor.inventories.getLast();click(menus,actor.player,leaderRoot,10,false);var eligible=actor.inventories.getLast();click(menus,actor.player,eligible,0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();click(menus,actor.player,actor.inventories.getLast(),22,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.invitationsFor(member.core.record.id()).isEmpty());click(menus,actor.player,actor.inventories.getLast(),10,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));var memberRoot=member.inventories.getLast();click(menus,member.player,memberRoot,15,false);var invitationMenu=member.inventories.getLast();click(menus,member.player,invitationMenu,0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.partyOf(member.core.record.id()).isPresent());controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));click(menus,actor.player,actor.inventories.getLast(),13,false);assertEquals(List.of(net.kyori.adventure.text.Component.text("Left click: Kick (free)"),net.kyori.adventure.text.Component.text("Right click: Transfer leadership — 6 coins")),actor.inventories.getLast().getItem(0).getItemMeta().lore());click(menus,actor.player,actor.inventories.getLast(),0,true);characters.find(UUID.randomUUID()).join();parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals(member.core.record.id(),parties.partyOf(old.id()).orElseThrow().leader());assertEquals(BigInteger.valueOf(7),characters.find(old.id()).join().orElseThrow().coins());
var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(actor.world);f.players.put(member.core.id,member.player);f.activate(member);invitedPlayer=member.core.id;invitedCharacter=member.core.record.id();invitedSession=((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(member.core.id).orElseThrow().session();var leaderRoot=actor.inventories.getLast();click(menus,actor.player,leaderRoot,10,false);var eligible=actor.inventories.getLast();click(menus,actor.player,eligible,0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();click(menus,actor.player,actor.inventories.getLast(),22,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.invitationsFor(member.core.record.id()).isEmpty());click(menus,actor.player,actor.inventories.getLast(),10,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));var memberRoot=member.inventories.getLast();click(menus,member.player,memberRoot,15,false);var invitationMenu=member.inventories.getLast();click(menus,member.player,invitationMenu,0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.partyOf(member.core.record.id()).isPresent());controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));click(menus,actor.player,actor.inventories.getLast(),13,false);assertEquals(List.of(net.kyori.adventure.text.Component.text("Left click: Kick (free)"),net.kyori.adventure.text.Component.text("Right click: Transfer leadership — 6 coins")),actor.inventories.getLast().getItem(0).getItemMeta().lore());click(menus,actor.player,actor.inventories.getLast(),0,true);characters.find(UUID.randomUUID()).join();parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals(member.core.record.id(),parties.partyOf(old.id()).orElseThrow().leader());assertEquals(BigInteger.valueOf(7),characters.find(old.id()).join().orElseThrow().coins());
controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));click(menus,member.player,member.inventories.getLast(),13,false);click(menus,member.player,member.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.partyOf(old.id()).isEmpty());controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));click(menus,member.player,member.inventories.getLast(),10,false);click(menus,member.player,member.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));click(menus,actor.player,actor.inventories.getLast(),15,false);click(menus,actor.player,actor.inventories.getLast(),0,true);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));click(menus,member.player,member.inventories.getLast(),10,false);click(menus,member.player,member.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));click(menus,actor.player,actor.inventories.getLast(),15,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));click(menus,actor.player,actor.inventories.getLast(),13,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));click(menus,member.player,member.inventories.getLast(),16,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.partyOf(member.core.record.id()).isEmpty());
f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"party-registrar","list"});assertTrue(actor.messages.stream().anyMatch(message->message.contains(registrar.id().toString())&&message.contains("Central")));when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[]{villager});controller.reconcile(chunk);verify(actor.world,times(1)).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));var damage=new org.bukkit.event.entity.EntityDamageEvent(villager,org.bukkit.event.entity.EntityDamageEvent.DamageCause.FIRE,1);controller.damage(damage);assertTrue(damage.isCancelled());var combust=new org.bukkit.event.entity.EntityCombustEvent(villager,5);controller.combust(combust);assertTrue(combust.isCancelled());var interaction=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);controller.interact(interaction);assertTrue(interaction.isCancelled());var leash=new org.bukkit.event.entity.PlayerLeashEntityEvent(villager,actor.player,actor.player,EquipmentSlot.HAND);controller.leash(leash);assertTrue(leash.isCancelled());
when(actor.world.getEntities()).thenReturn(List.of(villager));f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"party-registrar","remove",registrar.id().toString()});directory.remove(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(directory.find(registrar.id()).isEmpty());verify(villager).remove();
PartyBookNativeTest.drain(f);worldEntities.clear();worldEntities.add(villager);f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"party-registrar","remove",registrar.id().toString()});directory.remove(UUID.randomUUID()).join();PartyBookNativeTest.drain(f);assertTrue(directory.find(registrar.id()).isEmpty());verify(villager).remove();
}
var records=events(data);var types=records.stream().map(event->event.get("type").getAsString()).toList();for(var type:List.of("party_registrar.placed","party_registrar.removed","party.created","party.invitation_created","party.invitation_cancelled","party.invitation_declined","party.joined","party.left","party.member_kicked","party.leadership_transferred","party.disbanded"))assertTrue(types.stream().anyMatch(value->value.endsWith(type)),()->type+" absent from "+types);assertEquals(4,types.stream().filter(value->value.endsWith("party.invitation_created")).count());assertEquals(2,records.stream().filter(event->event.get("type").getAsString().endsWith("config.changed")&&event.getAsJsonObject("data").get("key").getAsString().startsWith("party-")).count());assertEquals(2,records.stream().filter(event->event.get("type").getAsString().endsWith("config.read")&&event.getAsJsonObject("data").get("key").getAsString().startsWith("party-")).count());var created=records.stream().filter(event->event.get("type").getAsString().endsWith("party.created")).findFirst().orElseThrow().getAsJsonObject("data");assertEquals("7",created.get("charged_coins").getAsString());assertEquals("requested",created.get("reason").getAsString());assertTrue(created.has("party_id"));assertTrue(created.has("registrar_id"));assertTrue(created.has("registrar_location"));assertEquals("not_applicable",created.get("popup_delivery").getAsString());var invited=records.stream().filter(event->event.get("type").getAsString().endsWith("party.invitation_created")).findFirst().orElseThrow().getAsJsonObject("data");assertEquals(invitedPlayer.toString(),invited.get("target_player_id").getAsString());assertEquals(invitedCharacter.toString(),invited.get("target_character_id").getAsString());assertEquals(invitedSession.toString(),invited.get("target_session_id").getAsString());var placed=records.stream().filter(event->event.get("type").getAsString().endsWith("party_registrar.placed")).findFirst().orElseThrow().getAsJsonObject("data");assertEquals("loaded",placed.get("entity_status").getAsString());assertTrue(placed.has("entity_id"));
}
private static java.util.List<com.google.gson.JsonObject> events(Path data)throws Exception{var records=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}return records;}
private static void click(games.dmg.fantasystick.party.PartyRegistrarMenus menus,org.bukkit.entity.Player player,org.bukkit.inventory.Inventory inventory,int slot,boolean right){var event=mock(org.bukkit.event.inventory.InventoryClickEvent.class);when(event.getInventory()).thenReturn(inventory);when(event.getWhoClicked()).thenReturn(player);when(event.getCurrentItem()).thenReturn(inventory.getItem(slot));when(event.isRightClick()).thenReturn(right);menus.click(event);}
}
@@ -73,6 +73,18 @@ class PurifyNativeTest {
learnAndAssign(fixture, actor); use(fixture, actor);
assertTrue(actor.messages.stream().anyMatch(message -> message.contains("ready in")));
}
var recorded = events(data);
for (var type : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved", "combat.cleansed"))
assertEquals(type.equals("ability.learned") || type.equals("ability.assigned") ? 2 : 1,
recorded.stream().filter(event -> event.get("type").getAsString().endsWith(type)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("purify")).count(), type);
var cleansed = recorded.stream().filter(event -> event.get("type").getAsString().endsWith("combat.cleansed")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("purify")).findFirst().orElseThrow();
var names = new java.util.HashSet<String>();
cleansed.getAsJsonObject("data").getAsJsonArray("removed_effects").forEach(value -> names.add(value.getAsString()));
assertEquals(java.util.Set.of("POISON", "WITHER", "SLOWNESS", "WEAKNESS"), names);
assertEquals("4", cleansed.getAsJsonObject("data").get("actual_count").getAsString());
}
@Test void aimedAllyActualCleanseCreatesPersistedSupportShareThatFollowsSourceCharacter() throws Exception {
@@ -102,6 +114,23 @@ class PurifyNativeTest {
assertEquals(BigInteger.valueOf(5), storage(fixture).find(ally.core.record.id()).get().orElseThrow().experience());
assertTrue(holder.messages.stream().anyMatch(message -> message.contains("5") && message.contains("XP")));
}
var allyCleanse = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.cleansed")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("purify")).toList();
assertEquals(1, allyCleanse.size());
assertEquals("POISON", allyCleanse.getFirst().getAsJsonObject("data")
.getAsJsonArray("removed_effects").get(0).getAsString());
assertFalse(allyCleanse.getFirst().getAsJsonObject("data").get("self_target").getAsBoolean());
}
private static List<com.google.gson.JsonObject> events(Path root) throws Exception {
var values = new ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(root.resolve("events"))) {
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(file))
if (!line.isBlank()) values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return values;
}
@Test void normalSelfOrdinaryNonPartyOutOfRangeObstructedAndNoOpTargetsRefuseBeforeCooldown() throws Exception {
@@ -125,6 +154,9 @@ class PurifyNativeTest {
assertFalse(storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("purify"),
"route " + route + " must refuse before cooldown");
}
assertEquals(0, events(directory).stream().filter(event -> event.get("type").getAsString().endsWith("combat.cleansed")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("purify")).count());
}
}
@@ -145,6 +177,14 @@ class PurifyNativeTest {
assertTrue(effects.containsKey(PotionEffectType.POISON));
assertNotNull(storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("purify"));
}
var records = events(data);
assertEquals(0, records.stream().filter(event -> event.get("type").getAsString().endsWith("combat.cleansed")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("purify")).count());
assertEquals(1, records.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("purify")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("cancelled")).count());
}
@Test void selfCleanseNeverEmitsSupportEvidence() {
@@ -196,6 +236,19 @@ class PurifyNativeTest {
effects(fixture, ally.player).put(PotionEffectType.SLOWNESS, instance(PotionEffectType.SLOWNESS, 200, 0));
doReturn(false).when(caster.player).isSneaking(); use(fixture, caster); drain(fixture, 2);
assertNull(ally.player.getPotionEffect(PotionEffectType.SLOWNESS));
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());
fixture.command.execute(sender.port, "fstickadmin",
new String[]{"config", "purify-range-blocks", "21"});
var failed = fixture.tasks.poll(5, TimeUnit.SECONDS);
assertNotNull(failed); failed.run();
}
assertEquals("20", ((games.dmg.fantasystick.persistence.PurifySettings)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "purifySettings"))
.value("purify-range-blocks"));
assertTrue(sender.messages.getLast().contains("save failed"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "purify-range-blocks", "0"});
assertTrue(sender.messages.getLast().contains("invalid"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "purify-range-blocks", "20", "extra"});
@@ -205,6 +258,14 @@ class PurifyNativeTest {
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
assertEquals(0, new BigDecimal("20").compareTo(reloaded.find("purify").orElseThrow().parameters().get("range")));
}
var config = events(data).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("purify-range-blocks")).toList();
assertEquals(2, config.size());
assertEquals("16", config.getFirst().getAsJsonObject("data").get("after").getAsString());
assertEquals("16", config.getLast().getAsJsonObject("data").get("before").getAsString());
assertEquals("20", config.getLast().getAsJsonObject("data").get("after").getAsString());
for (String invalid : List.of("purify-cooldown-seconds: -1\n", "purify-range-blocks: 0\n")) {
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
java.nio.file.Files.writeString(directory.resolve("config.yml"), invalid);
@@ -94,6 +94,12 @@ class QuickerRegenerationNativeTest {
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
assertEquals(refunded.passives(), storage(f).find(refunded.id()).get().orElseThrow().passives());
}
var events=events(data);var learnedEvents=events.stream().filter(event->event.get("type").getAsString().endsWith("passive.learned")).toList();var unlearnedEvents=events.stream().filter(event->event.get("type").getAsString().endsWith("passive.unlearned")).toList();assertEquals(1,learnedEvents.size());assertEquals(1,unlearnedEvents.size());var learned=learnedEvents.getFirst().getAsJsonObject("data");assertEquals("quicker_regeneration_i",learned.get("passive_id").getAsString());assertEquals("40",learned.get("points_before").getAsString());assertEquals("39",learned.get("points_after").getAsString());assertEquals(0,learned.get("passive_slot").getAsInt());assertEquals("12",learned.getAsJsonObject("stat_prerequisites").get("CON").getAsString());var unlearned=unlearnedEvents.getFirst().getAsJsonObject("data");assertEquals("39",unlearned.get("points_before").getAsString());assertEquals("40",unlearned.get("points_after").getAsString());assertEquals("passive_state_acknowledged",unlearned.get("owned_cleanup").getAsString());var healed=events.stream().filter(event->event.get("type").getAsString().endsWith("combat.healed")).map(event->event.getAsJsonObject("data")).filter(value->value.has("recovery_kind")&&value.get("recovery_kind").getAsString().equals("out_of_combat")).toList();assertEquals(1,healed.size());assertEquals("4.0",healed.getFirst().get("accepted_total").getAsString());assertEquals("3.0",healed.getFirst().getAsJsonObject("passive_contributions").get("quicker_regeneration_i").getAsString());assertEquals("acknowledged",healed.getFirst().get("native_publication").getAsString());
}
@Test void failedPassiveSaveResolvesWithoutInventingLearnedStateOrSuccess() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);makeEligible(data,a,BigDecimal.valueOf(4),List.of("","",""));f.players.put(a.core.id,a.player);f.activate(a);PartyBookNativeTest.open(f,a);var pages=nativePages(a);var qri=pages.get(changePageTo(pages.get(first(pages,"Passive index")),pages,"Quicker Regeneration I").page()-1);var learn=custom(List.of(qri),"fantasystick:passive_unlock");try(var channel=java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"),java.nio.file.StandardOpenOption.WRITE);var lock=channel.lock()){assertTrue(lock.isValid());PartyBookNativeTest.click(f,a,learn);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}assertFalse(storage(f).find(a.core.record.id()).get().orElseThrow().passives().contains("quicker_regeneration_i"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);f.plugin.setEnabled(false);f.awaitWorkers();}
var events=events(data);assertEquals(0,events.stream().filter(event->event.get("type").getAsString().endsWith("passive.learned")).count());assertTrue(events.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("passive_learn")).map(event->event.getAsJsonObject("data")).anyMatch(value->value.get("outcome").getAsString().equals("failed")&&value.get("reason").getAsString().equals("save_or_native_publication_uncertain")),events::toString);
}
@Test void multiPagePassiveIndexAndWrappedDetailsReturnToTheirExactContainingPage() throws Exception {
@@ -176,9 +182,13 @@ class QuickerRegenerationNativeTest {
var settings = (games.dmg.fantasystick.persistence.QuickerRegenerationSettings)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "quickerRegenerationSettings");
assertEquals("4", settings.value());
try(var channel=java.nio.channels.FileChannel.open(data.resolve(".config.lock"),java.nio.file.StandardOpenOption.CREATE,java.nio.file.StandardOpenOption.WRITE);var lock=channel.lock()){assertTrue(lock.isValid());f.command.execute(sender.port,"fantasystick",new String[]{"config","quicker-regeneration-i-bonus-healing","5"});var failed=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(failed);failed.run();}assertEquals("4",settings.value());assertTrue(sender.messages.getLast().contains("save failed"));
}
var events=events(data);var reads=events.stream().filter(event->event.get("type").getAsString().endsWith("config.read")&&event.getAsJsonObject("data").get("key").getAsString().equals("quicker-regeneration-i-bonus-healing")).toList();var changes=events.stream().filter(event->event.get("type").getAsString().endsWith("config.changed")&&event.getAsJsonObject("data").get("key").getAsString().equals("quicker-regeneration-i-bonus-healing")).toList();assertEquals(1,reads.size());assertEquals("1",reads.getFirst().getAsJsonObject("data").get("after").getAsString());assertEquals(1,changes.size());assertEquals("1",changes.getFirst().getAsJsonObject("data").get("before").getAsString());assertEquals("4",changes.getFirst().getAsJsonObject("data").get("after").getAsString());assertEquals("future_recovery_pulses",changes.getFirst().getAsJsonObject("data").get("application").getAsString());
}
private static java.util.List<com.google.gson.JsonObject> events(Path data)throws Exception{var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}return events;}
private static void makeEligible(Path data, FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health,
List<String> passives) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
@@ -70,6 +70,16 @@ class RallyingCryNativeTest {
var hit = new EntityDamageByEntityEvent(ally.player, enemy, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
fixture.dispatcher.accept(hit); assertEquals(12, hit.getDamage(), 1.0e-9);
assertEquals(1, caster.messages.stream().filter("Rallying Cry added 2 damage against Zombie."::equals).count());
class Veto implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
public void veto(EntityDamageByEntityEvent event) {
if (event.getEntity() == enemy) event.setCancelled(true);
}
}
fixture.dispatcher.register(new Veto(), fixture.plugin);
var cancelled = new EntityDamageByEntityEvent(ally.player, enemy,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
fixture.dispatcher.accept(cancelled); assertTrue(cancelled.isCancelled());
fixture.dispatcher.accept(new EntityDeathEvent(enemy, source, new ArrayList<>(), 0)); drain(fixture, 4);
assertEquals(BigInteger.valueOf(5), storage(fixture).find(caster.core.record.id()).get().orElseThrow().experience());
assertEquals(BigInteger.valueOf(5), storage(fixture).find(ally.core.record.id()).get().orElseThrow().experience());
@@ -85,6 +95,31 @@ class RallyingCryNativeTest {
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "rallying_cry")); drain(fixture, 2);
use(fixture, caster); assertTrue(caster.messages.stream().anyMatch(message -> message.contains("ready in")));
}
var records = events(data);
for (var type : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved",
"combat.boosted"))
assertEquals(type.equals("ability.learned") || type.equals("ability.assigned") ? 2 : 1,
records.stream().filter(event -> event.get("type").getAsString().endsWith(type)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("rallying_cry")).count(), type);
for (var type : List.of("effect.applied", "effect.ended"))
assertEquals(3, records.stream().filter(event -> event.get("type").getAsString().endsWith(type)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("rallying_cry")).count(), type);
var boosted = records.stream().filter(event -> event.get("type").getAsString().endsWith("combat.boosted")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("rallying_cry")).findFirst().orElseThrow();
assertEquals("2", boosted.getAsJsonObject("data").get("actual_amount").getAsString());
assertEquals("12", boosted.getAsJsonObject("data").get("final_amount").getAsString());
}
private static List<com.google.gson.JsonObject> events(Path root) throws Exception {
var values = new ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(root.resolve("events"))) {
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(file))
if (!line.isBlank()) values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return values;
}
@Test void dynamicSphereMembershipSessionAndSourceValidityRecheckOwnedBenefits() throws Exception {
@@ -199,6 +234,19 @@ class RallyingCryNativeTest {
var futureTarget = mock(LivingEntity.class); when(futureTarget.getUniqueId()).thenReturn(UUID.randomUUID());
var future = hit(ally.player, futureTarget, 10); fixture.dispatcher.accept(future);
assertEquals(14, future.getDamage(), 1.0e-9);
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());
fixture.command.execute(sender.port, "fstickadmin",
new String[]{"config", "rallying-cry-radius-blocks", "13"});
var failed = fixture.tasks.poll(5, TimeUnit.SECONDS);
assertNotNull(failed); failed.run();
}
assertEquals("12", ((games.dmg.fantasystick.persistence.RallyingCrySettings)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "rallyingCrySettings"))
.value("rallying-cry-radius-blocks"));
assertTrue(sender.messages.getLast().contains("save failed"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "rallying-cry-duration-seconds", "0"});
assertTrue(sender.messages.getLast().contains("invalid"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "rallying-cry-radius-blocks", "12", "extra"});
@@ -211,6 +259,17 @@ class RallyingCryNativeTest {
assertEquals(0, new BigDecimal("9").compareTo(saved.parameters().get("duration-seconds")));
assertEquals(new BigDecimal("2"), saved.parameters().get("speed-amplifier"));
}
var config = events(data).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().startsWith("rallying-cry-")).toList();
assertEquals(6, config.size());
assertEquals("10", config.getFirst().getAsJsonObject("data").get("after").getAsString());
var radius = config.stream().filter(event -> event.get("type").getAsString().endsWith("config.changed")
&& event.getAsJsonObject("data").get("key").getAsString().equals("rallying-cry-radius-blocks"))
.findFirst().orElseThrow();
assertEquals("10", radius.getAsJsonObject("data").get("before").getAsString());
assertEquals("12", radius.getAsJsonObject("data").get("after").getAsString());
for (String invalid : List.of("rallying-cry-cooldown-seconds: -1\n", "rallying-cry-radius-blocks: 0\n",
"rallying-cry-duration-seconds: 0\n", "rallying-cry-speed-amplifier: -1\n",
"rallying-cry-damage-bonus-percent: 101\n")) {
@@ -45,10 +45,11 @@ class ReviveNativeTest {
assertEquals(BigInteger.ZERO, saved.unspentPoints());
assertEquals(org.bukkit.Material.ECHO_SHARD, actor.core.inventory.getItem(2).getType());
}
var records=events(data);var learned=records.stream().filter(event->event.get("type").getAsString().endsWith("ability.learned")&&event.getAsJsonObject("data").get("ability_id").getAsString().equals("revive")).toList();var assigned=records.stream().filter(event->event.get("type").getAsString().endsWith("ability.assigned")&&event.getAsJsonObject("data").get("ability_id").getAsString().equals("revive")).toList();assertEquals(1,learned.size());assertEquals(1,assigned.size());assertEquals("0",learned.getFirst().getAsJsonObject("data").get("points_after").getAsString());assertEquals(0,assigned.getFirst().getAsJsonObject("data").get("ability_slot").getAsInt());assertEquals(learned.getFirst().getAsJsonObject("data").get("action_id").getAsString(),assigned.getFirst().getAsJsonObject("data").get("action_id").getAsString());
}
@Test void learnedAimedReviveRestoresAnyPartyDownedCharacterInPlace()throws Exception{
try(var fixture=new FantasyStickLifecycleNativeTest.Fixture(data)){fixture.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);withRevive(data,caster);var target=new FantasyStickLifecycleNativeTest.Actor(data);fixture.players.put(caster.core.id,caster.player);fixture.players.put(target.core.id,target.player);var worldId=java.util.UUID.randomUUID();org.mockito.Mockito.when(caster.world.getUID()).thenReturn(worldId);org.mockito.Mockito.when(target.world.getUID()).thenReturn(worldId);fixture.activate(caster);fixture.activate(target);caster.core.location.setWorld(caster.world);caster.core.location.setX(0);caster.core.location.setY(64);caster.core.location.setZ(0);target.core.location.setWorld(caster.world);target.core.location.setX(3);target.core.location.setY(64);target.core.location.setZ(0);var anchor=target.core.location.clone();org.mockito.Mockito.doReturn(true).when(caster.player).hasLineOfSight(target.player);target.core.health=0;var source=new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.GENERIC)));fixture.dispatcher.accept(new org.bukkit.event.entity.PlayerDeathEvent(target.player,source,new java.util.ArrayList<>(),0,net.kyori.adventure.text.Component.empty(),true));var downed=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin,"downedController");for(int attempt=0;attempt<80&&!downed.downed(target.player);attempt++){var task=fixture.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}caster.core.inventory.getInventory().setSelectedSlot(2);fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player,target.player,org.bukkit.inventory.EquipmentSlot.HAND));for(int attempt=0;attempt<120&&downed.downed(target.player);attempt++){var task=fixture.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}for(int attempt=0;attempt<80&&caster.messages.stream().noneMatch(message->message.contains("restored the downed"));attempt++){var task=fixture.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin,"sessions");assertFalse(downed.downed(target.player),()->"caster="+caster.messages+", target="+target.messages);assertTrue(sessions.current(sessions.active(target.core.id).orElseThrow()));assertEquals(target.maximumHealth.getValue(),target.player.getHealth());assertEquals(anchor,target.player.getLocation());assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,target.core.inventory.getItemInOffHand().getType());var casterRecord=sessions.currentRecord(sessions.active(caster.core.id).orElseThrow()).orElseThrow();assertTrue(casterRecord.cooldowns().get("revive").isAfter(java.time.Instant.now().plusSeconds(86300)));fixture.plugin.setEnabled(false);var records=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var types=records.stream().map(event->event.get("type").getAsString()).toList();assertTrue(types.contains("games.dmg.minecraft.fantasystick.ability.cast"),types.toString());assertTrue(types.contains("games.dmg.minecraft.fantasystick.ability.resolved"),types.toString());assertEquals(1,types.stream().filter("games.dmg.minecraft.fantasystick.character.downed_resolved"::equals).count(),types.toString());var resolutionEvent=records.stream().filter(event->event.get("type").getAsString().endsWith("character.downed_resolved")).findFirst().orElseThrow();var resolution=resolutionEvent.getAsJsonObject("data");assertEquals(caster.core.id.toString(),resolutionEvent.get("playerid").getAsString());assertEquals(caster.core.record.id().toString(),resolutionEvent.get("characterid").getAsString());assertEquals(caster.core.record.id().toString(),resolution.get("actor_character_id").getAsString());assertEquals(3.0,resolution.get("final_range").getAsDouble());assertTrue(resolution.get("line_of_sight").getAsBoolean());assertFalse(types.stream().anyMatch(type->type.contains("phoenix")||type.endsWith("character.revived")));}
try(var fixture=new FantasyStickLifecycleNativeTest.Fixture(data)){fixture.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);withRevive(data,caster);var target=new FantasyStickLifecycleNativeTest.Actor(data);fixture.players.put(caster.core.id,caster.player);fixture.players.put(target.core.id,target.player);var worldId=java.util.UUID.randomUUID();org.mockito.Mockito.when(caster.world.getUID()).thenReturn(worldId);org.mockito.Mockito.when(target.world.getUID()).thenReturn(worldId);fixture.activate(caster);fixture.activate(target);caster.core.location.setWorld(caster.world);caster.core.location.setX(0);caster.core.location.setY(64);caster.core.location.setZ(0);target.core.location.setWorld(caster.world);target.core.location.setX(3);target.core.location.setY(64);target.core.location.setZ(0);var anchor=target.core.location.clone();org.mockito.Mockito.doReturn(true).when(caster.player).hasLineOfSight(target.player);target.core.health=0;var source=new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.GENERIC)));fixture.dispatcher.accept(new org.bukkit.event.entity.PlayerDeathEvent(target.player,source,new java.util.ArrayList<>(),0,net.kyori.adventure.text.Component.empty(),true));var downed=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin,"downedController");for(int attempt=0;attempt<80&&!downed.downed(target.player);attempt++){var task=fixture.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}caster.core.inventory.getInventory().setSelectedSlot(2);fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player,target.player,org.bukkit.inventory.EquipmentSlot.HAND));for(int attempt=0;attempt<120&&downed.downed(target.player);attempt++){var task=fixture.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}for(int attempt=0;attempt<80&&caster.messages.stream().noneMatch(message->message.contains("restored the downed"));attempt++){var task=fixture.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin,"sessions");assertFalse(downed.downed(target.player),()->"caster="+caster.messages+", target="+target.messages);assertTrue(sessions.current(sessions.active(target.core.id).orElseThrow()));assertEquals(target.maximumHealth.getValue(),target.player.getHealth());assertEquals(anchor,target.player.getLocation());assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,target.core.inventory.getItemInOffHand().getType());var casterRecord=sessions.currentRecord(sessions.active(caster.core.id).orElseThrow()).orElseThrow();assertTrue(casterRecord.cooldowns().get("revive").isAfter(java.time.Instant.now().plusSeconds(86300)));fixture.plugin.setEnabled(false);var records=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var types=records.stream().map(event->event.get("type").getAsString()).toList();assertTrue(types.contains("games.dmg.minecraft.fantasystick.ability.cast"),types.toString());assertTrue(types.contains("games.dmg.minecraft.fantasystick.ability.resolved"),types.toString());assertEquals(1,types.stream().filter("games.dmg.minecraft.fantasystick.character.downed_resolved"::equals).count(),types.toString());var resolutionEvent=records.stream().filter(event->event.get("type").getAsString().endsWith("character.downed_resolved")).findFirst().orElseThrow();var resolution=resolutionEvent.getAsJsonObject("data");assertEquals(caster.core.id.toString(),resolutionEvent.get("playerid").getAsString());assertEquals(caster.core.record.id().toString(),resolutionEvent.get("characterid").getAsString());assertEquals(caster.core.record.id().toString(),resolution.get("actor_character_id").getAsString());assertEquals(3.0,resolution.get("final_range").getAsDouble());assertTrue(resolution.get("line_of_sight").getAsBoolean());assertFalse(types.stream().anyMatch(type->type.contains("phoenix")||type.endsWith("character.revived")));assertEquals(1,records.stream().filter(event->event.get("type").getAsString().endsWith("effect.applied")).map(event->event.get("id").getAsString()).distinct().count());var applied=records.stream().filter(event->event.get("type").getAsString().endsWith("effect.applied")).findFirst().orElseThrow().getAsJsonObject("data");assertEquals("revive_downed_invulnerability",applied.get("effect_id").getAsSLine truncated
}
@Test void ordinaryTargetSelectsOneTailAndReceivesTheSameLivingSheetOnce() throws Exception {
@@ -126,6 +127,7 @@ class ReviveNativeTest {
assertTrue(storage.find(caster.core.record.id()).get().orElseThrow().cooldowns().get("revive")
.isAfter(java.time.Instant.now().plusSeconds(86300)));
}
var records=events(data);var revived=records.stream().filter(event->event.get("type").getAsString().endsWith("character.revived")).toList();assertEquals(1,revived.size());var value=revived.getFirst().getAsJsonObject("data");assertEquals("learned_tail",value.get("revival_kind").getAsString());assertEquals(10,value.get("source_slot").getAsInt());assertEquals(1,value.get("destination_slot").getAsInt());assertEquals("2",value.get("source_quantity_before").getAsString());assertEquals("1",value.get("source_quantity_after").getAsString());assertEquals("1",value.get("consumed_quantity").getAsString());assertTrue(value.get("no_auto_entry").getAsBoolean());assertEquals("tail",value.get("selected_branch").getAsString());assertEquals(1,records.stream().filter(event->event.get("type").getAsString().endsWith("ability.cast")&&event.getAsJsonObject("data").has("selected_branch")&&event.getAsJsonObject("data").get("selected_branch").getAsString().equals("tail")).count());assertEquals(1,records.stream().filter(event->event.get("type").getAsString().endsWith("ability.resolved")&&event.getAsJsonObject("data").has("selected_branch")&&event.getAsJsonObject("data").get("selected_branch").getAsString().equals("tail")&&event.getAsJsonObject("data").get("outcome").getAsString().equals("restored")).count());
}
@Test void stackedTailWithNoSafeSheetDestinationChangesNothingAndKeepsTheCooldown() throws Exception {
@@ -342,6 +344,7 @@ class ReviveNativeTest {
10, 1, -1);
try (var store = new games.dmg.fantasystick.persistence.RevivalStore(data)) { store.reserve(reservation).join(); }
var untouchedReservationId=java.util.UUID.randomUUID();
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.players.put(target.core.id, target.player); fixture.plugin.setEnabled(true);
var store = (games.dmg.fantasystick.persistence.RevivalStore)
@@ -361,7 +364,7 @@ class ReviveNativeTest {
secondBase.stats(), secondBase.level(), secondBase.experience(), secondBase.unspentPoints(), java.math.BigDecimal.ZERO,
secondBase.learned(), secondBase.loadout(), secondBase.cooldowns(), secondBase.restrictedKills(), true, secondDeath, died);
repository.create(second); var tail = new games.dmg.fantasystick.items.TailBooks().create(second); target.core.inventory.setItem(12, tail);
var untouched = new games.dmg.fantasystick.persistence.RevivalStore.Reservation(java.util.UUID.randomUUID(),
var untouched = new games.dmg.fantasystick.persistence.RevivalStore.Reservation(untouchedReservationId,
java.util.UUID.randomUUID(), target.core.id, second.id(), secondDeath,
java.time.Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS), 12, 1, -1);
store.reserve(untouched).join();
@@ -370,6 +373,7 @@ class ReviveNativeTest {
assertTrue(storage.find(second.id()).get().orElseThrow().dead());
assertEquals(tail, target.core.inventory.getItem(12)); assertTrue(store.find(second.id()).join().isEmpty());
}
var recovered=events(data).stream().filter(event->event.get("type").getAsString().endsWith("character.revived")&&event.getAsJsonObject("data").get("reservation_id").getAsString().equals(reservation.id().toString())).toList();assertFalse(recovered.isEmpty());assertEquals(1,recovered.stream().map(event->event.get("id").getAsString()).distinct().count());assertTrue(recovered.getFirst().getAsJsonObject("data").get("recovery_reconciliation").getAsBoolean());assertEquals(0,events(data).stream().filter(event->event.get("type").getAsString().endsWith("character.revived")&&event.getAsJsonObject("data").get("reservation_id").getAsString().equals(untouchedReservationId.toString())).count());
}
private static net.minecraft.network.chat.ClickEvent.Custom abilityButton(
@@ -395,6 +399,8 @@ class ReviveNativeTest {
return null;
}
private static java.util.List<com.google.gson.JsonObject> events(Path data)throws Exception{var records=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}return records;}
private static void withRevive(Path data, FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
@@ -43,6 +43,19 @@ class SanctuaryNativeTest {
var viewer=new FantasyStickLifecycleNativeTest.Actor(data);viewer.core.inventory.setItemInOffHand(null);viewer.core.location.setWorld(caster.world);fixture.players.put(viewer.core.id,viewer.player);
PartyBookNativeTest.disableAllParticles(fixture,caster);caster.particleCalls.clear();viewer.particleCalls.clear();
use(fixture,caster);drain(fixture,2);assertEquals(2L,fixture.delayedTicks.getLast());
var incomingAttacker=mock(org.bukkit.entity.LivingEntity.class);when(incomingAttacker.getUniqueId()).thenReturn(UUID.randomUUID());
var incomingSource=DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK)
.withCausingEntity(incomingAttacker).withDirectEntity(incomingAttacker).build();
var incoming=new EntityDamageByEntityEvent(incomingAttacker,ally.player,EntityDamageEvent.DamageCause.ENTITY_ATTACK,incomingSource,10);
fixture.dispatcher.accept(incoming);assertEquals(8,incoming.getFinalDamage(),1.0e-9);
class LateVeto implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler(priority=org.bukkit.event.EventPriority.HIGHEST)
public void cancel(EntityDamageByEntityEvent event){if(event.getDamager()==incomingAttacker)event.setCancelled(true);}
}
fixture.dispatcher.register(new LateVeto(),fixture.plugin);
var vetoed=new EntityDamageByEntityEvent(incomingAttacker,ally.player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK,incomingSource,10);
fixture.dispatcher.accept(vetoed);assertTrue(vetoed.isCancelled());
for(int i=0;i<10;i++)fixture.delayed.removeFirst().run();drain(fixture,4);assertEquals(6,ally.core.health,0);
assertTrue(caster.particleCalls.isEmpty(),"The opted-out caster receives no Sanctuary particles");assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==org.bukkit.Particle.END_ROD&&call[2].equals(12)),"An enabled nearby viewer retains Sanctuary visuals");
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
@@ -54,6 +67,61 @@ class SanctuaryNativeTest {
assertEquals(BigInteger.valueOf(5),storage.find(caster.core.record.id()).get().orElseThrow().experience());
assertEquals(BigInteger.valueOf(5),storage.find(ally.core.record.id()).get().orElseThrow().experience());
}
var records=events(data);
for(var type:List.of("ability.learned","ability.assigned","ability.cast","ability.resolved","combat.prevented"))
assertEquals(1,abilityEvents(records,type).size(),type);
var heals=abilityEvents(records,"combat.healed");
assertTrue(heals.size()>=1,"Actual acknowledged ally-health pulses are individually recorded");
assertEquals(heals.size(),heals.stream().map(event->event.getAsJsonObject("data").get("application_id").getAsString()).distinct().count());
assertEquals(3,abilityEvents(records,"effect.applied").size(),"One fixed area plus caster/ally benefits");
assertEquals(3,abilityEvents(records,"effect.ended").size(),"No per-tick duplicate grants");
var heal=heals.stream().map(event->event.getAsJsonObject("data"))
.filter(value->value.get("health_before").getAsString().equals("4")).findFirst().orElseThrow();
assertEquals("2",heal.get("actual_amount").getAsString());
assertEquals("6",heal.get("health_after").getAsString());
assertTrue(heal.has("target_location"));
assertEquals("2",abilityEvents(records,"combat.prevented").getFirst().getAsJsonObject("data")
.get("actual_amount").getAsString());
}
@Test void cancelledNativeAllyHealCannotClaimSavedOrEffectiveHealth()throws Exception{
UUID allyId;
try(var fixture=new FantasyStickLifecycleNativeTest.Fixture(data)){
fixture.plugin.setEnabled(true);
var caster=new FantasyStickLifecycleNativeTest.Actor(data);
var ally=new FantasyStickLifecycleNativeTest.Actor(data);allyId=ally.core.id;
prepareCaster(data,caster,true);setHealth(data,ally,BigDecimal.valueOf(4));
fixture.players.put(caster.core.id,caster.player);fixture.players.put(allyId,ally.player);
fixture.activate(caster);fixture.activate(ally);party(fixture,caster,ally);
caster.core.location=new Location(caster.world,0,64,0);ally.core.location=new Location(caster.world,2,64,0);
doReturn(List.of(ally.player)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());
class Veto implements org.bukkit.event.Listener{
@org.bukkit.event.EventHandler(priority=org.bukkit.event.EventPriority.HIGH)
public void cancel(EntityRegainHealthEvent event){if(event.getEntity()==ally.player)event.setCancelled(true);}
}
fixture.dispatcher.register(new Veto(),fixture.plugin);
use(fixture,caster);drain(fixture,2);
for(int i=0;i<10;i++)fixture.delayed.removeFirst().run();drain(fixture,4);
assertEquals(4,ally.core.health,0);
}
assertTrue(abilityEvents(events(data),"combat.healed").stream()
.noneMatch(event->event.getAsJsonObject("data").get("target_player_id").getAsString().equals(allyId.toString())),
"Cancelled ally healing must not become a saved-health success or a JSONL healing claim");
}
private static List<com.google.gson.JsonObject> events(Path root)throws Exception{
var values=new ArrayList<com.google.gson.JsonObject>();
try(var files=java.nio.file.Files.walk(root.resolve("events"))){
for(var file:files.filter(path->path.toString().endsWith(".jsonl")).toList())
for(var line:java.nio.file.Files.readAllLines(file))
if(!line.isBlank())values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return values;
}
private static List<com.google.gson.JsonObject> abilityEvents(List<com.google.gson.JsonObject> events,String suffix){
return events.stream().filter(event->event.get("type").getAsString().endsWith(suffix)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("sanctuary")).toList();
}
@Test void fixedCylinderHasNoImmediatePulseThenSixOwnedHealingAndMitigationPulses() {
@@ -145,6 +213,16 @@ class SanctuaryNativeTest {
plugin.command.execute(sender.port,"fantasystick",new String[]{"config",update[0],update[1]});var response=plugin.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(response);response.run();}
var configured=registry.find("sanctuary").orElseThrow();assertEquals(new BigDecimal("4"),configured.parameters().get("healing-per-pulse"));
assertEquals(new BigDecimal("30"),configured.parameters().get("damage-reduction-percent"));assertEquals(java.time.Duration.ofSeconds(20),configured.cooldown());
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());
plugin.command.execute(sender.port,"fstickadmin",new String[]{"config","sanctuary-radius-blocks","9"});
var failed=plugin.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(failed);failed.run();
}
assertEquals("8",((games.dmg.fantasystick.persistence.SanctuarySettings)
FantasyStickLifecycleNativeTest.Fixture.field(plugin.plugin,"sanctuarySettings")).value("sanctuary-radius-blocks"));
assertTrue(sender.messages.getLast().contains("save failed"));
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-radius-blocks","0"});assertTrue(sender.messages.getLast().contains("invalid"));
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-radius-blocks","8","extra"});assertTrue(sender.messages.getLast().contains("Usage"));
plugin.plugin.setEnabled(false);plugin.plugin.setEnabled(true);
@@ -152,6 +230,11 @@ class SanctuaryNativeTest {
var saved=reloaded.find("sanctuary").orElseThrow();assertEquals(new BigDecimal("4"),saved.parameters().get("healing-per-pulse"));
assertEquals(new BigDecimal("30"),saved.parameters().get("damage-reduction-percent"));assertEquals(java.time.Duration.ofSeconds(20),saved.cooldown());
}
var config=events(data).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().startsWith("sanctuary-")).toList();
assertEquals(8,config.size(),"Authorized read and seven saved settings only");
}
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture,FantasyStickLifecycleNativeTest.Actor actor){actor.core.inventory.getInventory().setSelectedSlot(2);fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(actor.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,actor.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,org.bukkit.inventory.EquipmentSlot.HAND));}
@@ -36,13 +36,45 @@ class WarBannerNativeTest {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);var ally=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false,false);f.players.put(caster.core.id,caster.player);f.players.put(ally.core.id,ally.player);f.activate(caster);f.activate(ally);party(f,caster,ally);placement(caster);nearby(caster,List.of(ally.player));var resistance=attribute(ally.player);resistance.modifiers().add(new AttributeModifier(new NamespacedKey("test","unrelated_knockback"),.2,AttributeModifier.Operation.ADD_NUMBER));
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("WarBanner"));assertTrue(text.contains("STR22"));assertTrue(text.contains("CHR22"));assertTrue(text.contains("CON12"));assertTrue(text.contains("25%"));assertTrue(text.contains("50%"));
PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","war_banner"));drain(f,2);var viewer=new FantasyStickLifecycleNativeTest.Actor(data);viewer.core.inventory.setItemInOffHand(null);viewer.core.location.setWorld(caster.world);f.players.put(viewer.core.id,viewer.player);PartyBookNativeTest.disableAllParticles(f,caster);caster.particleCalls.clear();viewer.particleCalls.clear();use(f,caster);drain(f,2);assertTrue(caster.particleCalls.isEmpty(),"The opted-out caster receives no War Banner particles");assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.DUST&&call[2].equals(16)),"An enabled nearby viewer retains War Banner visuals");assertEquals(200L,f.delayedTicks.getLast());assertEquals(.7,resistance.value(),1e-9);assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("war_banner"));
var enemy=mock(LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(enemy.getLocation()).thenReturn(new Location(caster.world,4,65,0));when(caster.world.getUID()).thenReturn(UUID.randomUUID());var source=DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(ally.player).withDirectEntity(ally.player).build();var hit=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(hit);assertEquals(12.5,hit.getDamage(),1e-9);f.dispatcher.accept(new EntityDeathEvent(enemy,source,new ArrayList<>(),0));drain(f,4);assertEquals(BigInteger.valueOf(5),storage(f).find(caster.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage(f).find(ally.core.record.id()).get().orElseThrow().experience());
var enemy=mock(LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(enemy.getLocation()).thenReturn(new Location(caster.world,4,65,0));when(caster.world.getUID()).thenReturn(UUID.randomUUID());var source=DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(ally.player).withDirectEntity(ally.player).build();var hit=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(hit);assertEquals(12.5,hit.getDamage(),1e-9);
class LateVeto implements org.bukkit.event.Listener{
@org.bukkit.event.EventHandler(priority=org.bukkit.event.EventPriority.HIGHEST)
public void cancel(EntityDamageByEntityEvent event){if(event.getEntity()==enemy)event.setCancelled(true);}
}
f.dispatcher.register(new LateVeto(),f.plugin);
var cancelled=hit(ally.player,enemy,10);f.dispatcher.accept(cancelled);assertTrue(cancelled.isCancelled());
f.dispatcher.accept(new EntityDeathEvent(enemy,source,new ArrayList<>(),0));drain(f,4);assertEquals(BigInteger.valueOf(5),storage(f).find(caster.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage(f).find(ally.core.record.id()).get().orElseThrow().experience());
f.delayed.getLast().run();assertEquals(.2,resistance.value(),1e-9,"Owned cleanup preserves unrelated knockback modifiers");var expired=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(expired);assertEquals(10,expired.getDamage(),1e-9);
}
var records=events(data);
for(var type:List.of("ability.learned","ability.assigned","ability.cast","ability.resolved","combat.boosted"))
assertEquals(1,abilityEvents(records,type).size(),type);
assertEquals(2,abilityEvents(records,"effect.applied").size(),"Fixed banner and the actually eligible ally benefit");
assertEquals(2,abilityEvents(records,"effect.ended").size(),"Expiry closes only actually owned benefits");
var boosted=abilityEvents(records,"combat.boosted").getFirst().getAsJsonObject("data");
assertEquals("2.5",boosted.get("actual_amount").getAsString());
assertEquals("12.5",boosted.get("final_amount").getAsString());
assertTrue(boosted.has("target_location"));
}
private static List<com.google.gson.JsonObject> events(Path root)throws Exception{
var values=new ArrayList<com.google.gson.JsonObject>();
try(var files=java.nio.file.Files.walk(root.resolve("events"))){
for(var file:files.filter(path->path.toString().endsWith(".jsonl")).toList())
for(var line:java.nio.file.Files.readAllLines(file))
if(!line.isBlank())values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return values;
}
private static List<com.google.gson.JsonObject> abilityEvents(List<com.google.gson.JsonObject> events,String suffix){
return events.stream().filter(event->event.get("type").getAsString().endsWith(suffix)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("war_banner")).toList();
}
@Test void invalidOrNonTopPlacementRefusesBeforeCooldown()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true,false);f.players.put(caster.core.id,caster.player);f.activate(caster);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("war_banner"));placement(caster,12,BlockFace.NORTH);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("war_banner"));placement(caster);use(f,caster);drain(f,2);assertTrue(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("war_banner"));}
assertEquals(1,abilityEvents(events(data),"ability.cast").size(),"Only the actual solid-top placement commits a cast");
}
@Test void fixedCylinderObstructionLifecycleOverlapAndRallyUseStrongestOnly()throws Exception{
@@ -65,7 +97,21 @@ class WarBannerNativeTest {
for(String[] update:List.of(new String[]{WarBannerSettings.COOLDOWN,"3"},new String[]{WarBannerSettings.PLACEMENT_RANGE,"11"},new String[]{WarBannerSettings.RADIUS,"9"},new String[]{WarBannerSettings.VERTICAL,"5"},new String[]{WarBannerSettings.DURATION,"9"},new String[]{WarBannerSettings.DAMAGE_BONUS,"35"},new String[]{WarBannerSettings.KNOCKBACK_RESISTANCE,"60"})){f.command.execute(sender.port,"fantasystick",new String[]{"config",update[0],update[1]});var response=f.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(response);response.run();}
var saved=registry.find("war_banner").orElseThrow();assertEquals(java.time.Duration.ofSeconds(3),saved.cooldown());assertEquals(new BigDecimal("35"),saved.parameters().get("damage-bonus-percent"));assertEquals(new BigDecimal("60"),saved.parameters().get("knockback-resistance-percent"));
var caster=new FantasyStickLifecycleNativeTest.Actor(data);var ally=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true,false);f.players.put(caster.core.id,caster.player);f.players.put(ally.core.id,ally.player);f.activate(caster);f.activate(ally);party(f,caster,ally);placement(caster,11);nearby(caster,List.of(ally.player));var resistance=attribute(ally.player);use(f,caster);drain(f,2);assertEquals(180L,f.delayedTicks.getLast());assertEquals(.6,resistance.value(),1e-9);var enemy=mock(LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());var hit=hit(ally.player,enemy,10);f.dispatcher.accept(hit);assertEquals(13.5,hit.getDamage(),1e-9);
try(var channel=java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
java.nio.file.StandardOpenOption.CREATE,java.nio.file.StandardOpenOption.WRITE);
var lock=channel.lock()){
assertTrue(lock.isValid());
f.command.execute(sender.port,"fstickadmin",new String[]{"config",WarBannerSettings.RADIUS,"10"});
var failed=f.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(failed);failed.run();
}
assertEquals("9",((WarBannerSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"warBannerSettings")).value(WarBannerSettings.RADIUS));
assertTrue(sender.messages.getLast().contains("save failed"));
f.command.execute(sender.port,"fantasystick",new String[]{"config",WarBannerSettings.RADIUS,"0"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config",WarBannerSettings.RADIUS,"9","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reloaded=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new BigDecimal("35"),reloaded.find("war_banner").orElseThrow().parameters().get("damage-bonus-percent"));}
var config=events(data).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().startsWith("war-banner-")).toList();
assertEquals(8,config.size(),"Authorized read and seven saved updates, without denied/invalid successes");
}
private record AttributeFixture(Set<AttributeModifier> modifiers){double value(){return modifiers.stream().mapToDouble(AttributeModifier::getAmount).sum();}}
@@ -0,0 +1,61 @@
package games.dmg.fantasystick.events;
import static org.junit.jupiter.api.Assertions.*;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class AbilityCastEventBridgeTest {
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-09-25T04:00:00Z"), ZoneOffset.UTC);
@Test void explicitEffectEvidenceCannotReplaceTheOwnedAbilityIdentityOrOutcome() {
var records = new ArrayList<EventInput>();
var bridge = new AbilityCastEventBridge(records::add, CLOCK);
var action = UUID.randomUUID();
var cast = new AbilityCastEventBridge.Cast(action,
new EventInput.Context(UUID.randomUUID(), "Account", UUID.randomUUID(), "Hero", UUID.randomUUID(), null),
"test_focus", null, null, Map.of(), CLOCK.instant());
var application = bridge.effectApplied(cast, "test_effect", null,
Map.of("ability_id", "forged", "outcome", "forged", "action_kind", "forged", "actual_amount", 2));
bridge.effectEnded(cast, application, "test_effect", "expired");
assertEquals(2, records.size());
var applied = records.getFirst();
assertEquals("test_focus", applied.data().get("ability_id"));
assertEquals("applied", applied.data().get("outcome"));
assertEquals("ability_cast", applied.data().get("action_kind"));
assertEquals(2, applied.data().get("actual_amount"));
assertEquals(application.toString(), applied.data().get("application_id"));
assertEquals(application.toString(), records.getLast().data().get("application_id"));
assertNotEquals(applied.id(), records.getLast().id());
assertEquals(action, applied.actionId());
assertEquals(action, records.getLast().actionId());
}
@Test void ownerCanResolvePriorNativeCancellationWithoutInventingACastOrEffect() {
var records = new ArrayList<EventInput>();
var bridge = new AbilityCastEventBridge(records::add, CLOCK);
var cast = new AbilityCastEventBridge.Cast(UUID.randomUUID(),
new EventInput.Context(UUID.randomUUID(), "Account", UUID.randomUUID(), "Hero", UUID.randomUUID(), null),
"test_focus", null, null, Map.of(), CLOCK.instant());
bridge.resolved(cast, "cancelled", "prior_native_cancellation");
assertEquals(1, records.size());
assertEquals("ability.resolved", records.getFirst().type());
assertEquals("cancelled", records.getFirst().data().get("outcome"));
assertEquals("prior_native_cancellation", records.getFirst().data().get("reason"));
}
@Test void aBrokenSinkCannotChangeAnEffectApplicationOrResolution() {
var bridge = new AbilityCastEventBridge(event -> { throw new IllegalStateException("writer unavailable"); }, CLOCK);
var cast = new AbilityCastEventBridge.Cast(UUID.randomUUID(),
new EventInput.Context(UUID.randomUUID(), "Account", UUID.randomUUID(), "Hero", UUID.randomUUID(), null),
"test_focus", null, null, Map.of(), CLOCK.instant());
var application = assertDoesNotThrow(() -> bridge.effectApplied(cast, "native_effect", null, Map.of("actual_amount", 1)));
assertDoesNotThrow(() -> bridge.effectEnded(cast, application, "native_effect", "expired"));
assertDoesNotThrow(() -> bridge.resolved(cast, "completed", "effect_finished_without_hit_claim"));
}
}
@@ -0,0 +1,52 @@
package games.dmg.fantasystick.events;
import static org.junit.jupiter.api.Assertions.*;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class AbilityConfigEventBridgeTest {
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-09-25T04:00:00Z"), ZoneOffset.UTC);
@Test void authorizedReadAndAcknowledgedChangeHaveDistinctTypedRecords() {
var events = new ArrayList<EventInput>();
var bridge = new AbilityConfigEventBridge(events::add, CLOCK);
var actor = new AbilityConfigEventBridge.Actor("console", null, null);
bridge.read(actor, "fireball", "fireball-cooldown-seconds", "1.2");
bridge.changed(actor, "fireball", "fireball-cooldown-seconds", "1.2", "2");
assertEquals(2, events.size());
assertEquals("config.read", events.getFirst().type());
assertEquals("config.changed", events.getLast().type());
assertNotEquals(events.getFirst().id(), events.getLast().id());
assertEquals("fireball", events.getLast().data().get("ability_id"));
assertEquals("1.2", events.getLast().data().get("before"));
assertEquals("2", events.getLast().data().get("after"));
assertEquals("future_casts_only", events.getLast().data().get("application"));
assertNull(events.getLast().context(), "Console is not a fictional player or character");
}
@Test void identityCaptureFailureCannotBlockAnAuthorizedReadOrSave() {
var bridge = new AbilityConfigEventBridge(event -> fail("No unsupported identity may be emitted"), CLOCK);
var player = (org.bukkit.entity.Player) java.lang.reflect.Proxy.newProxyInstance(
org.bukkit.entity.Player.class.getClassLoader(), new Class<?>[]{org.bukkit.entity.Player.class},
(proxy, method, args) -> {
if (method.getName().equals("getUniqueId")) throw new IllegalStateException("identity unavailable");
return null;
});
var actor = assertDoesNotThrow(() -> bridge.capture(player));
assertNull(actor);
assertDoesNotThrow(() -> bridge.read(actor, "fireball", "fireball-range-blocks", "32"));
assertDoesNotThrow(() -> bridge.changed(actor, "fireball", "fireball-range-blocks", "32", "40"));
}
@Test void aBrokenLogSinkCannotRefuseAnAlreadyAuthorizedConfigurationAction() {
var bridge = new AbilityConfigEventBridge(event -> { throw new IllegalStateException("writer down"); }, CLOCK);
var actor = new AbilityConfigEventBridge.Actor("player", UUID.randomUUID(), "Admin");
assertDoesNotThrow(() -> bridge.read(actor, "fireball", "fireball-range-blocks", "32"));
assertDoesNotThrow(() -> bridge.changed(actor, "fireball", "fireball-range-blocks", "32", "40"));
}
}
@@ -0,0 +1,13 @@
package games.dmg.fantasystick.events;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.progression.CoinRules;
import games.dmg.fantasystick.progression.ProgressionRules;
import java.time.Clock;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class CoinEventBridgeTest {
@Test void eventFailureCannotChangeInitializationOrFinalDeathAuthority(){var record=new games.dmg.fantasystick.crafting.CharacterFactory().create(ProgressionRules.defaults());var dead=new games.dmg.fantasystick.persistence.CharacterRecord(record.id(),record.name(),record.revision().add(java.math.BigInteger.ONE),record.stats(),record.level(),record.experience(),record.unspentPoints(),java.math.BigDecimal.ZERO,record.learned(),record.loadout(),record.passives(),record.cooldowns(),record.restrictedKills(),true,UUID.randomUUID(),Clock.systemUTC().instant(),java.math.BigInteger.ZERO,true,record.rewardReceipts(),record.coins(),record.gear(),record.sheetGeneration());var bridge=new CoinEventBridge(event->{throw new IllegalStateException("disk");},Clock.systemUTC());assertDoesNotThrow(()->{bridge.initialized(record,"craft",CoinRules.defaults());bridge.lost(record,dead,dead.deathId(),dead.diedAt());bridge.configRead("console",null,null,"starting-coins-min","1");});}
}
@@ -0,0 +1,21 @@
package games.dmg.fantasystick.events;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.CombatRewardRules;
import games.dmg.fantasystick.progression.ProgressionRules;
import java.math.BigInteger;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class CombatRewardEventBridgeTest {
@Test void sinkFailureCannotChangeAnAcknowledgedRewardAndIdentityIsStable(){var before=new games.dmg.fantasystick.crafting.CharacterFactory().create(ProgressionRules.defaults());var receipt=UUID.randomUUID();var position=new CombatRewardRules.KillPosition(UUID.randomUUID(),java.math.BigDecimal.ZERO,java.math.BigDecimal.valueOf(64),java.math.BigDecimal.ZERO);var history=new HashMap<>(before.restrictedKills());history.put(position.world(),position);var receipts=new HashSet<>(before.rewardReceipts());receipts.add(receipt);var after=new CharacterRecord(before.id(),before.name(),before.revision().add(BigInteger.ONE),before.stats(),before.level(),before.experience().add(BigInteger.TEN),before.unspentPoints(),before.health(),before.learned(),before.loadout(),before.passives(),before.cooldowns(),history,false,before.deathId(),before.diedAt(),before.coins().add(BigInteger.TEN),true,receipts,before.pvpCoinEscrow(),before.gear(),before.sheetGeneration());var award=new CharacterStorage.CombatAward(CharacterStorage.CombatAwardStatus.APPLIED,Optional.of(after),0);var capture=new CombatRewardEventBridge.Capture(UUID.randomUUID(),"zombie","ordinary","hostile",position,null,"mob_defeat",Instant.parse("2026-09-25T00:00:00Z"),java.util.List.of(before.id()),before.id().toString(),1,BigInteger.TEN,BigInteger.TEN);var bridge=new CombatRewardEventBridge(event->{throw new IllegalStateException("disk");},Clock.fixed(Instant.parse("2026-09-25T00:00:01Z"),ZoneOffset.UTC));assertDoesNotThrow(()->bridge.resolved(receipt,before.id(),BigInteger.TEN,BigInteger.TEN,position,CombatRewardRules.defaults(),capture,before,award,null,null,"not_applicable_offline","not_delivered_offline"));}
}
@@ -30,7 +30,7 @@ final class DownedEventBridgeTest {
events.reconcile(List.of(pending, resolved));
assertEquals(List.of("character.downed", "character.downed", "ability.cast",
"character.downed_resolved", "ability.resolved", "action.resolved", "effect.ended"),
"character.downed_resolved", "ability.resolved", "action.resolved", "effect.applied", "effect.ended"),
first.stream().map(EventInput::type).toList());
assertEquals(first.stream().map(EventInput::id).toList(),
captured.subList(first.size(), captured.size()).stream().map(EventInput::id).toList());
@@ -56,7 +56,10 @@ final class DownedEventBridgeTest {
assertEquals(true, resolution.data().get("line_of_sight"));
assertEquals("decision_won", first.get(4).data().get("outcome"));
assertEquals("completed", first.get(5).data().get("outcome"));
assertEquals(resolved.protectionUntil(), first.get(6).time());
assertEquals("revive_downed_invulnerability",first.get(6).data().get("effect_id"));
assertEquals(resolved.protectionUntil(),first.get(6).data().get("protection_until"));
assertEquals("applied",first.get(6).data().get("outcome"));
assertEquals(resolved.protectionUntil(), first.get(7).time());
assertFalse(first.stream().anyMatch(event -> event.type().contains("phoenix")));
}
@@ -59,6 +59,27 @@ final class LocalEventLogTest {
}
}
@Test void preservesRealNonPlayerAdminActorWithoutAllowingAContextFreePlayerImpersonation() throws Exception {
var clock = Clock.fixed(Instant.parse("2026-09-25T05:00:00Z"), ZoneOffset.UTC);
try (var events = LocalEventLog.open(data, clock,
new LocalEventLog.RuntimeInfo("Season 4", "26.2", "test"), 8, 134_217_728L,
message -> fail(message))) {
events.ready().toCompletableFuture().get();
for (var actor : java.util.List.of("console", "command_block", "player")) {
var action = UUID.randomUUID();
assertEquals(LocalEventLog.Delivery.EXPORTED,
events.emit(new EventInput(action, action, clock.instant(), "config.read", null,
Map.of("actor_kind", actor, "key", actor))).toCompletableFuture().get());
}
}
var lines = jsonLines(data.resolve("events"));
for (var actor : java.util.List.of("console", "command_block"))
assertTrue(java.util.Arrays.stream(lines).anyMatch(line -> line.contains("\"key\":\"" + actor + "\"")
&& line.contains("\"actor_kind\":\"" + actor + "\"")));
assertTrue(java.util.Arrays.stream(lines).anyMatch(line -> line.contains("\"key\":\"player\"")
&& line.contains("\"actor_kind\":\"system\"")));
}
@Test void pendingEvidenceAloneReportsRecoveryWithoutInventingPriorShutdown() throws Exception {
var root=data.resolve("events");Files.createDirectories(root.resolve("pending"));var server=UUID.randomUUID();Files.writeString(root.resolve("server-id"),server+"\n");var event=UUID.randomUUID();var exact="{\"specversion\":\"1.0\",\"id\":\""+event+"\",\"source\":\"urn:minecraft-server:"+server+"\",\"type\":\"games.dmg.minecraft.fantasystick.action.observed\",\"time\":\"2026-09-24T20:00:00Z\",\"datacontenttype\":\"application/json\",\"data\":{\"schema_version\":1}}\n";Files.writeString(root.resolve("pending/"+event+".pending"),exact);
try(var recovered=LocalEventLog.open(data,Clock.fixed(Instant.parse("2026-09-25T00:00:00Z"),ZoneOffset.UTC),new LocalEventLog.RuntimeInfo("Season 4","26.2","test"),8,134_217_728L,message->fail(message))){assertEquals(LocalEventLog.Delivery.EXPORTED,recovered.ready().toCompletableFuture().get());}
@@ -0,0 +1,29 @@
package games.dmg.fantasystick.events;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.party.CharacterParty;
import games.dmg.fantasystick.party.CharacterPartyDirectory;
import games.dmg.fantasystick.party.PartyManagement;
import games.dmg.fantasystick.party.PartyRegistrar;
import java.math.BigInteger;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class PartyEventBridgeTest {
@Test void everyPersistentMutationMapsToOneFeatureOutcomeAndFinalDeathIsStable(){
var captured=new ArrayList<EventInput>();var clock=Clock.fixed(Instant.parse("2026-09-25T00:00:00Z"),ZoneOffset.UTC);var bridge=new PartyEventBridge(captured::add,clock);var leader=UUID.randomUUID();var member=UUID.randomUUID();var outsider=UUID.randomUUID();var partyId=UUID.randomUUID();var registrar=new PartyRegistrar(UUID.randomUUID(),"Central",UUID.randomUUID(),1,2,3,4,5);var context=new EventInput.Context(UUID.randomUUID(),"Account",leader,"Leader",UUID.randomUUID(),null);var base=new CharacterParty(partyId,BigInteger.ZERO,"Heroes","heroes",leader,Set.of(leader,member),List.of());
for(var kind:List.of("create","invite","cancel","decline","accept","leave","kick","transfer","disband")){var before=kind.equals("create")?null:base;var invitation=kind.equals("cancel")||kind.equals("decline")||kind.equals("accept")?UUID.randomUUID():null;var target=kind.equals("invite")?outsider:kind.equals("kick")||kind.equals("transfer")?member:null;var action=new PartyEventBridge.Action(UUID.randomUUID(),context,registrar,kind,target,invitation,before,kind.equals("create")?BigInteger.TEN:BigInteger.ZERO);var after=switch(kind){case "create"->base;case "invite"->new CharacterParty(partyId,BigInteger.ONE,"Heroes","heroes",leader,base.members(),List.of(new CharacterParty.Invitation(UUID.randomUUID(),outsider)));case "cancel","decline","accept","leave","kick","transfer","disband"->base;default->throw new AssertionError();};bridge.resolved(action,new PartyManagement.Outcome(PartyManagement.Status.APPLIED,Optional.of(after)));}
var types=captured.stream().filter(event->event.type().startsWith("party.")).map(EventInput::type).toList();for(var type:List.of("party.created","party.invitation_created","party.invitation_cancelled","party.invitation_declined","party.joined","party.left","party.member_kicked","party.leadership_transferred","party.disbanded"))assertTrue(types.contains(type),()->type+" absent from "+types);var disbanded=captured.stream().filter(event->event.type().equals("party.disbanded")).findFirst().orElseThrow();assertEquals("requested",disbanded.data().get("reason"));assertEquals("active_former_member_delivery_queued",disbanded.data().get("popup_delivery"));assertFalse(disbanded.data().containsKey("after_revision"));
int before=captured.size();bridge.finalDefeat(member,base,CharacterPartyDirectory.Defeat.MEMBER_REMOVED);var first=captured.get(before);bridge.finalDefeat(member,base,CharacterPartyDirectory.Defeat.MEMBER_REMOVED);assertEquals("party.left",first.type());assertEquals("final_death",first.data().get("reason"));assertEquals(first.id(),captured.getLast().id());
}
@Test void registrarConfigAndSinkFailuresRemainObservabilityOnly(){var captured=new ArrayList<EventInput>();var bridge=new PartyEventBridge(captured::add,Clock.systemUTC());var registrar=new PartyRegistrar(UUID.randomUUID(),"Central",UUID.randomUUID(),1,2,3,4,5);bridge.registrarPlaced("console",null,null,registrar,UUID.randomUUID(),true);bridge.registrarRemoved("console",null,null,registrar,null,false);bridge.configRead("console",null,null,"party-registration-price","10");bridge.configChanged("console",null,null,"party-registration-price","10","7");assertEquals(List.of("party_registrar.placed","party_registrar.removed","config.read","config.changed"),captured.stream().map(EventInput::type).toList());var broken=new PartyEventBridge(event->{throw new IllegalStateException("disk");},Clock.systemUTC());assertDoesNotThrow(()->{broken.registrarPlaced("console",null,null,registrar,null,false);var leader=UUID.randomUUID();broken.finalDefeat(leader,new CharacterParty(UUID.randomUUID(),BigInteger.ZERO,"X","x",leader,Set.of(leader),List.of()),CharacterPartyDirectory.Defeat.PARTY_DISBANDED);});}
}
@@ -30,7 +30,8 @@ class ChainLightningSettingsTest {
var cooldown = settings.set(ChainLightningSettings.COOLDOWN, "24").toCompletableFuture();
var savedRange = range.get(5, TimeUnit.SECONDS); var savedCooldown = cooldown.get(5, TimeUnit.SECONDS);
assertEquals(6, settings.settings().jumpRange(), 0);
settings.publish(savedCooldown); settings.publish(savedRange);
assertTrue(settings.publish(savedCooldown));
assertFalse(settings.publish(savedRange), "Stale save callbacks cannot republish policy or claim configuration success");
assertEquals(7, settings.settings().jumpRange(), 0);
assertEquals(java.time.Duration.ofSeconds(24), settings.settings().cooldown());
var yaml = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
@@ -29,21 +29,23 @@ class CharacterStorageTest {
BigInteger.ZERO, false, java.util.Set.of());
repository.create(legacy);
var rules = new games.dmg.fantasystick.progression.CoinRules(4, 4, 1, 5, 25, 50);
try (var storage = new CharacterStorage(repository, Clock.systemUTC(), () -> rules, new java.util.Random(7))) {
try (var storage = new CharacterStorage(repository, Clock.systemUTC(), () -> rules, new java.util.Random(7))) {var emitted=new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();storage.attachCoinEvents(new games.dmg.fantasystick.events.CoinEventBridge(emitted::add,Clock.systemUTC()));
var migrated = storage.find(legacy.id()).join().orElseThrow();
assertTrue(migrated.coinsInitialized());
assertEquals(BigInteger.valueOf(4), migrated.coins());
assertEquals(BigInteger.ONE, migrated.revision());
assertEquals(BigInteger.ONE, migrated.revision());assertEquals(1,emitted.size());assertEquals("character.coins_initialized",emitted.getFirst().type());assertEquals("legacy_migration",emitted.getFirst().data().get("source"));assertEquals("4",emitted.getFirst().data().get("balance"));
assertEquals(migrated, storage.find(legacy.id()).join().orElseThrow());
var spent = new CharacterRecord(migrated.id(), migrated.name(), migrated.revision().add(BigInteger.ONE),
migrated.stats(), migrated.level(), migrated.experience(), migrated.unspentPoints(), migrated.health(),
migrated.learned(), migrated.loadout(), migrated.passives(), migrated.cooldowns(), migrated.restrictedKills(),
migrated.dead(), migrated.deathId(), migrated.diedAt(), BigInteger.ZERO, true, migrated.rewardReceipts());
repository.replace(spent, migrated.revision());
assertEquals(BigInteger.ZERO, storage.find(legacy.id()).join().orElseThrow().coins());
assertEquals(BigInteger.ZERO, storage.find(legacy.id()).join().orElseThrow().coins());assertEquals(1,emitted.size(),"Initialization never rerolls or republishes after spending to zero");
}
}
@Test void claimedCraftAndAdminIssuePublishInitializationButAbandonedBatchDoesNot()throws Exception{var repository=new YamlCharacterRepository(data);var rules=games.dmg.fantasystick.progression.CoinRules.defaults();try(var storage=new CharacterStorage(repository,Clock.systemUTC(),()->rules,new java.util.Random(9))){var emitted=new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();storage.attachCoinEvents(new games.dmg.fantasystick.events.CoinEventBridge(emitted::add,Clock.systemUTC()));var factory=new games.dmg.fantasystick.crafting.CharacterFactory(()->rules);var crafted=factory.create(games.dmg.fantasystick.progression.ProgressionRules.defaults());var craft=storage.prepareCraft(java.util.List.of(crafted),"craft");var craftBatch=craft.ready().join();assertTrue(emitted.isEmpty());assertTrue(craftBatch.claimForDelivery().isPresent());assertEquals("craft",emitted.getFirst().data().get("source"));var issued=factory.create(games.dmg.fantasystick.progression.ProgressionRules.defaults());var admin=storage.prepareCraft(java.util.List.of(issued),"admin_issue");assertTrue(admin.ready().join().claimForDelivery().isPresent());assertEquals("admin_issue",emitted.get(1).data().get("source"));var abandoned=factory.create(games.dmg.fantasystick.progression.ProgressionRules.defaults());var abandonedPreparation=storage.prepareCraft(java.util.List.of(abandoned),"craft");abandonedPreparation.ready().join();abandonedPreparation.abandon().join();assertEquals(2,emitted.size());}}
@Test
void performsFileWritesOffTheCallerThreadAndAcknowledgesOnlyAfterCommit() throws Exception {
Thread caller = Thread.currentThread();
@@ -539,9 +541,11 @@ class CharacterStorageTest {
}
@Test void downedFinalDeathUsesCapturedResolutionTimeAndStableIdentityExactlyOnce()throws Exception{
var repository=new YamlCharacterRepository(data);var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());repository.create(base);UUID death=UUID.randomUUID();var expiry=java.time.Instant.parse("2026-09-24T02:05:00Z");try(var storage=new CharacterStorage(repository,Clock.systemUTC())){var first=storage.recordFinalDeath(base.id(),base.revision(),death,expiry,true).join().orElseThrow();assertTrue(first.dead());assertEquals(death,first.deathId());assertEquals(expiry,first.diedAt());assertEquals(BigInteger.ZERO,first.coins());assertEquals(base.coins(),first.pvpCoinEscrow());var duplicate=storage.recordFinalDeath(base.id(),base.revision(),death,expiry,true).join().orElseThrow();assertEquals(first,duplicate);assertTrue(storage.recordFinalDeath(base.id(),base.revision(),UUID.randomUUID(),expiry.plusSeconds(1),true).join().isEmpty());}
var repository=new YamlCharacterRepository(data);var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());repository.create(base);UUID death=UUID.randomUUID();var expiry=java.time.Instant.parse("2026-09-24T02:05:00Z");try(var storage=new CharacterStorage(repository,Clock.systemUTC())){var emitted=new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();storage.attachCoinEvents(new games.dmg.fantasystick.events.CoinEventBridge(emitted::add,Clock.systemUTC()));var first=storage.recordFinalDeath(base.id(),base.revision(),death,expiry,true).join().orElseThrow();assertTrue(first.dead());assertEquals(death,first.deathId());assertEquals(expiry,first.diedAt());assertEquals(BigInteger.ZERO,first.coins());assertEquals(base.coins(),first.pvpCoinEscrow());assertEquals(1,emitted.size());assertEquals("character.coins_lost",emitted.getFirst().type());assertEquals(base.coins().toString(),emitted.getFirst().data().get("balance_before"));var duplicate=storage.recordFinalDeath(base.id(),base.revision(),death,expiry,true).join().orElseThrow();assertEquals(first,duplicate);assertEquals(1,emitted.size());assertTrue(storage.recordFinalDeath(base.id(),base.revision(),UUID.randomUUID(),expiry.plusSeconds(1),true).join().isEmpty());}
}
@Test void failedFinalDeathSaveEmitsNoCoinLossAndOneRetryPublishesOnce()throws Exception{var durable=new YamlCharacterRepository(data);var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());durable.create(base);var events=new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();var death=UUID.randomUUID();var at=java.time.Instant.parse("2026-09-25T03:50:00Z");var failing=new YamlCharacterRepository(data,(source,target)->{throw new IOException("simulated");});try(var storage=new CharacterStorage(failing,Clock.systemUTC())){storage.attachCoinEvents(new games.dmg.fantasystick.events.CoinEventBridge(events::add,Clock.systemUTC()));assertThrows(CompletionException.class,()->storage.recordFinalDeath(base.id(),base.revision(),death,at,true).join());assertTrue(events.isEmpty());assertEquals(base,durable.load(base.id()).orElseThrow());}try(var storage=new CharacterStorage(durable,Clock.systemUTC())){storage.attachCoinEvents(new games.dmg.fantasystick.events.CoinEventBridge(events::add,Clock.systemUTC()));assertTrue(storage.recordFinalDeath(base.id(),base.revision(),death,at,true).join().isPresent());assertEquals(1,events.size());}}
@Test
void failedRevivalSaveLeavesDeadStateRetryableAndShutdownRejectsNewWork() throws Exception {
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
@@ -21,7 +21,8 @@ class FrostNovaSettingsTest {
var cooldown = settings.set(FrostNovaSettings.COOLDOWN, "24").toCompletableFuture();
var savedRange = range.get(5, TimeUnit.SECONDS); var savedCooldown = cooldown.get(5, TimeUnit.SECONDS);
assertEquals(5, settings.settings().radius(), 0);
settings.publish(savedCooldown); settings.publish(savedRange);
assertTrue(settings.publish(savedCooldown));
assertFalse(settings.publish(savedRange), "A late save callback cannot republish stale settings or claim an event");
assertEquals(7, settings.settings().radius(), 0);
assertEquals(java.time.Duration.ofSeconds(24), settings.settings().cooldown());
var yaml = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
@@ -12,7 +12,7 @@ class MeteorSettingsTest {
@TempDir Path data;
@Test void defaultsMatchApprovedPacket(){var s=MeteorSettings.parse(defaults());assertEquals(java.math.BigInteger.valueOf(14),s.baseDamage());assertEquals(Duration.ofSeconds(45),s.cooldown());assertEquals(24,s.targetRange());assertEquals(Duration.ofSeconds(2),s.warning());assertEquals(5,s.radius());assertEquals(4,s.verticalRange());}
@Test void validatesEveryBound(){for(var values:List.of(changed(MeteorSettings.BASE_DAMAGE,"-1"),changed(MeteorSettings.COOLDOWN,"-1"),changed(MeteorSettings.TARGET_RANGE,"0"),changed(MeteorSettings.WARNING,"0"),changed(MeteorSettings.WARNING,"3.2"),changed(MeteorSettings.RADIUS,"0"),changed(MeteorSettings.VERTICAL,"-1"),changed(MeteorSettings.RADIUS,"NaN")))assertThrows(IllegalArgumentException.class,()->MeteorSettings.parse(values));}
@Test void persistsBeforePublicationAndPreservesUnrelatedYaml()throws Exception{Files.writeString(data.resolve("config.yml"),"unrelated: keep\n");try(var store=new MeteorSettings(data,defaults())){var update=store.set(MeteorSettings.RADIUS,"6.00").toCompletableFuture().get();assertEquals("5",store.value(MeteorSettings.RADIUS));assertEquals("6",update.value());assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));store.publish(update);assertEquals("6",store.value(MeteorSettings.RADIUS));}}
@Test void persistsBeforePublicationAndPreservesUnrelatedYaml()throws Exception{Files.writeString(data.resolve("config.yml"),"unrelated: keep\n");try(var store=new MeteorSettings(data,defaults())){var update=store.set(MeteorSettings.RADIUS,"6.00").toCompletableFuture().get();assertEquals("5",store.value(MeteorSettings.RADIUS));assertEquals("6",update.value());assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));assertTrue(store.publish(update));assertEquals("6",store.value(MeteorSettings.RADIUS));var later=store.set(MeteorSettings.RADIUS,"7").toCompletableFuture().get();assertTrue(store.publish(later));assertFalse(store.publish(update),"A stale callback cannot claim publication twice");assertEquals("7",store.value(MeteorSettings.RADIUS));}}
@Test void failedUnsafeAndClosedWritesRetainEffectiveState()throws Exception{var fail=new java.util.concurrent.atomic.AtomicBoolean(true);try(var store=new MeteorSettings(data,defaults(),(from,to)->{if(fail.getAndSet(false))throw new java.io.IOException("simulated");Files.move(from,to,StandardCopyOption.REPLACE_EXISTING);})) {assertThrows(java.util.concurrent.ExecutionException.class,()->store.set(MeteorSettings.RADIUS,"6").toCompletableFuture().get());assertEquals("5",store.value(MeteorSettings.RADIUS));}if(!System.getProperty("os.name","").toLowerCase().contains("win")){var unsafe=data.resolve("unsafe");Files.createDirectories(unsafe);var outside=data.resolve("outside");Files.writeString(outside,"safe\n");Files.createSymbolicLink(unsafe.resolve("config.yml"),outside);try(var store=new MeteorSettings(unsafe,defaults())){assertThrows(java.util.concurrent.ExecutionException.class,()->store.set(MeteorSettings.RADIUS,"6").toCompletableFuture().get());assertEquals("safe\n",Files.readString(outside));}}var closed=new MeteorSettings(data.resolve("closed"),defaults());closed.close();assertThrows(java.util.concurrent.ExecutionException.class,()->closed.set(MeteorSettings.RADIUS,"6").toCompletableFuture().get());closed.drained().toCompletableFuture().get();}
private static Map<String,String> defaults(){var result=new LinkedHashMap<String,String>();for(var key:MeteorSettings.keys())result.put(key,MeteorSettings.defaultValue(key));return result;}
private static Map<String,String> changed(String key,String value){var result=new LinkedHashMap<>(defaults());result.put(key,value);return result;}
@@ -22,7 +22,9 @@ class SanctuarySettingsTest {
var radius = settings.set(SanctuarySettings.RADIUS, "7").toCompletableFuture();
var cooldown = settings.set(SanctuarySettings.COOLDOWN, "30").toCompletableFuture();
var savedRadius = radius.get(5, TimeUnit.SECONDS); var savedCooldown = cooldown.get(5, TimeUnit.SECONDS);
assertEquals(6, settings.settings().radius(), 0); settings.publish(savedCooldown); settings.publish(savedRadius);
assertEquals(6, settings.settings().radius(), 0);
assertTrue(settings.publish(savedCooldown));
assertFalse(settings.publish(savedRadius), "A stale save callback cannot republish policy or claim a config event");
assertEquals(7, settings.settings().radius(), 0); assertEquals(java.time.Duration.ofSeconds(30), settings.settings().cooldown());
var yaml = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals("retained", yaml.get("unrelated")); assertEquals("7", String.valueOf(yaml.get(SanctuarySettings.RADIUS)));
@@ -39,7 +39,11 @@ class WarBannerSettingsTest {
var update=store.set(WarBannerSettings.RADIUS,"9.00").toCompletableFuture().get();
assertEquals("8",store.value(WarBannerSettings.RADIUS));assertEquals("9",update.value());
assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));
store.publish(update);assertEquals("9",store.value(WarBannerSettings.RADIUS));
assertTrue(store.publish(update));assertEquals("9",store.value(WarBannerSettings.RADIUS));
var later=store.set(WarBannerSettings.RADIUS,"10").toCompletableFuture().get();
assertTrue(store.publish(later));
assertFalse(store.publish(update),"A stale callback cannot replace current settings or claim a second success");
assertEquals("10",store.value(WarBannerSettings.RADIUS));
}
}