This commit is contained in:
1 parent
ff28f2e98d
commit
2231dd058a
6 files changed
+101
-12
No files matched your search
@@ -730,6 +730,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
abilityEngine.enableCastEventsFor("frost_nova");
|
||||
abilityEngine.enableCastEventsFor("chain_lightning");
|
||||
abilityEngine.enableCastEventsFor("sanctuary");
|
||||
abilityEngine.enableCastEventsFor("war_banner");
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1485,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; }
|
||||
@@ -1493,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); }
|
||||
|
||||
@@ -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();}}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user