This commit is contained in:
1 parent
3163a491a0
commit
e3bc854481
9 files changed
+151
-15
No files matched your search
@@ -724,6 +724,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
abilityEngine.enableCastEventsFor("mending_light");
|
||||
abilityEngine.enableCastEventsFor("purify");
|
||||
abilityEngine.enableCastEventsFor("guardian_bond");
|
||||
abilityEngine.enableCastEventsFor("rallying_cry");
|
||||
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);
|
||||
|
||||
@@ -439,6 +439,12 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
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);
|
||||
|
||||
@@ -40,6 +40,10 @@ public interface AbilityExecution extends AutoCloseable {
|
||||
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. */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -188,6 +188,34 @@ public final class AbilityCastEventBridge {
|
||||
} 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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1358,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]); }
|
||||
@@ -1371,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;
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
Reference in new issue
Block a user