From ea760815bd10f9b429f04145e25b2afa58ada6d5 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Mon, 12 Oct 2026 14:39:55 -0400 Subject: [PATCH] feat(travel): grant finite native Speed I on Dirt Paths --- README.md | 17 + .../dmg/fantasystick/FantasyStickPlugin.java | 40 +- .../dmg/fantasystick/ability/RallyingCry.java | 32 +- .../book/CharacterBookController.java | 11 + .../events/PathSpeedEventBridge.java | 68 ++++ .../dmg/fantasystick/passive/PathSpeed.java | 271 +++++++++++++ .../passive/PersistentPotionEffect.java | 11 + .../dmg/fantasystick/passive/SpeedI.java | 1 + .../persistence/PathSpeedSettings.java | 150 +++++++ .../session/EquipmentAdminCommand.java | 42 ++ .../session/EquipmentSessions.java | 17 + src/main/resources/config.yml | 6 + ...nActionConfigurationCatalogNativeTest.java | 5 +- .../fantasystick/CharacterBookNativeTest.java | 2 + .../FantasyStickLifecycleNativeTest.java | 11 +- .../PassivePotionCleanupNativeTest.java | 14 +- .../PathSpeedConfigurationNativeTest.java | 145 +++++++ .../PathSpeedInterruptionNativeTest.java | 179 +++++++++ .../dmg/fantasystick/PathSpeedNativeTest.java | 370 ++++++++++++++++++ .../fantasystick/RallyingCryNativeTest.java | 4 +- .../dmg/fantasystick/DistributionTest.java | 4 + .../persistence/PathSpeedSettingsTest.java | 112 ++++++ 22 files changed, 1489 insertions(+), 23 deletions(-) create mode 100644 src/main/java/games/dmg/fantasystick/events/PathSpeedEventBridge.java create mode 100644 src/main/java/games/dmg/fantasystick/passive/PathSpeed.java create mode 100644 src/main/java/games/dmg/fantasystick/persistence/PathSpeedSettings.java create mode 100644 src/nativeTest/java/games/dmg/fantasystick/PathSpeedConfigurationNativeTest.java create mode 100644 src/nativeTest/java/games/dmg/fantasystick/PathSpeedInterruptionNativeTest.java create mode 100644 src/nativeTest/java/games/dmg/fantasystick/PathSpeedNativeTest.java create mode 100644 src/test/java/games/dmg/fantasystick/persistence/PathSpeedSettingsTest.java diff --git a/README.md b/README.md index fc676cd..0a878c5 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,23 @@ Exit, defeat and best-effort lifecycle/recovery cleanup restore only an owned Ad A same-process retry can acknowledge gear already verified restored for that exact player while retrying a vetoed mode restoration; a serialized `RESTORED` marker alone cannot establish this after restart. Missing game-mode proof leaves the mode and receipt retained even when independently verified gear can be restored and synchronized. Player-data and YAML saves are not one transaction; unobserved out-of-band state edits are not detectable history. No forced player save, blanket Survival reset or deployment is introduced. +## Dirt Path travel + +An alive, non-downed active character automatically receives native **Speed I** while grounded on foot on ordinary Dirt Paths, including their lowered surface. The character book explains this existing-sheet-and-road acquisition: no point cost, learned passive, slot, personal unlock or XP reward. Jumping, swimming, flying, gliding, mounting, inactive characters and ordinary players are ineligible. Reconciliation uses current custody/position and loaded terrain only, not proposed movement/teleport destinations or chunk loading. + +Road Speed does not stack with equal sources. Learned Speed I and Rallying Cry coordinate their persistent ownership with the short road lease; foreign/equal/stronger native effects and hidden chains are preserved, never surgically rewritten. Leaving eligibility requests only proved road-layer cleanup. Native refusal retains cleanup responsibility; unknown ownership conservatively relinquishes mutation authority. Interrupted cleanup can leave a finite lease until native ticking expires it, but cancellations can delay removal. Offline time is not native ticking, and ownership is not reconstructed across restarts. + +`/fstickadmin config [value]` requires `fantasystick.admin`; omission reads effective policy: + +| Key | Default | Accepted values | +| --- | --- | --- | +| `path-speed-enabled` | `true` | `true`, `false` | +| `path-speed-lease-ticks` | `20` | Integer `1–200` native ticks | + +Saves run off the tick thread, preserve unrelated YAML and queued keys, and require durable acknowledgment plus current callback authority before runtime publication. Lease changes affect subsequent eligible applications/refreshes, not an existing captured budget. False prevents grants/renewals and requests owned cleanup; true never activates a character. Completion is read-only. Startup rejects invalid typed settings and waits asynchronously for prior accepted configuration writes. + +JSONL `effect.applied`/`effect.ended` records use `feature_id=dirt_path_speed` with captured player/character/session/application identity and explicit native disposition. Continuous renewals are not new applications or movement logs. Logging failure/replay cannot authorize or replay speed. Native tests use explicit server/player/terrain/transport ports; no live movement, client rendering or deployment is claimed. + ## Passive potion cleanup boundaries Speed I and Low-Light Vision retain their exact holder/session, native effect-instance identity and displaced-effect evidence while native removal is cancelled or unconfirmed. Bounded current-lifetime sweeps retry after downed containment ends; legitimate rescue preserves the same current lease. Equal-looking foreign instances and stronger/unrelated effects are not cleanup authority. A prior snapshot can be restored only after confirmed owned removal into an empty destination and within its remaining lifetime; fixed native tiers and replacement policies are unchanged. diff --git a/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java b/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java index 9557392..1ca1fc8 100644 --- a/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java +++ b/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java @@ -133,6 +133,7 @@ public final class FantasyStickPlugin extends JavaPlugin { private games.dmg.fantasystick.persistence.AdminReviveSettings adminReviveSettings; private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings; private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings; + private games.dmg.fantasystick.persistence.PathSpeedSettings pathSpeedSettings; private games.dmg.fantasystick.persistence.PartySettings partySettings; private games.dmg.fantasystick.persistence.PartyEconomySettings partyEconomySettings; private games.dmg.fantasystick.persistence.GearSettings gearSettings; @@ -164,8 +165,11 @@ public final class FantasyStickPlugin extends JavaPlugin { private games.dmg.fantasystick.events.HealthStealEventBridge healthStealEvents; private java.util.concurrent.CompletionStage healthStealAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null); private games.dmg.fantasystick.events.PassivePotionEventBridge passivePotionEvents; + private games.dmg.fantasystick.events.PathSpeedEventBridge pathSpeedEvents; + private java.util.concurrent.CompletionStage pathSpeedAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null); private java.util.concurrent.CompletionStage passivePotionAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null); private games.dmg.fantasystick.passive.SpeedI speedI; + private games.dmg.fantasystick.passive.PathSpeed pathSpeed; private games.dmg.fantasystick.passive.LowLightVision lowLightVision; private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry; private games.dmg.fantasystick.passive.PassiveEngine passiveEngine; @@ -268,6 +272,9 @@ public final class FantasyStickPlugin extends JavaPlugin { adventureSettings = new games.dmg.fantasystick.persistence.AdventureSettings(getDataFolder().toPath(), adventure); var switchDelay = games.dmg.fantasystick.persistence.SwitchDelaySettings.parse( getConfig().get(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY)); + var pathInitial = games.dmg.fantasystick.persistence.PathSpeedSettings.load( + getConfig().get(games.dmg.fantasystick.persistence.PathSpeedSettings.ENABLED), + getConfig().get(games.dmg.fantasystick.persistence.PathSpeedSettings.LEASE)); var roleplayDisabled = games.dmg.fantasystick.persistence.RoleplaySettings.parse( getConfig().get(games.dmg.fantasystick.persistence.RoleplaySettings.KEY)); var partyMaximum = games.dmg.fantasystick.persistence.PartySettings.parse( @@ -611,6 +618,10 @@ public final class FantasyStickPlugin extends JavaPlugin { problem -> getLogger().log(Level.WARNING, "Health Steal failed for character " + problem.character(), problem.cause()), combatFeedback); speedI = new games.dmg.fantasystick.passive.SpeedI(sessions); + pathSpeedSettings = new games.dmg.fantasystick.persistence.PathSpeedSettings(getDataFolder().toPath(), pathInitial); + pathSpeed = new games.dmg.fantasystick.passive.PathSpeed(sessions, pathInitial); + speedI.attachRoad(pathSpeed); + rallyingCry.attachRoad(pathSpeed); lowLightVision = new games.dmg.fantasystick.passive.LowLightVision(sessions); passiveRegistry = new games.dmg.fantasystick.passive.PassiveRegistry(); passiveRegistry.register(quickerRegeneration.definition()); @@ -748,6 +759,7 @@ public final class FantasyStickPlugin extends JavaPlugin { admin.attachCharacterCoins(coinsService); admin.attachSwitchDelay(switchDelaySettings); admin.attachRoleplaySettings(roleplaySettings); + admin.attachPathSpeed(pathSpeedSettings, pathSpeed); admin.attachPartySettings(partySettings); admin.attachPartyEconomySettings(partyEconomySettings); admin.attachPartyRegistrars(partyRegistrars, partyRegistrarController); @@ -918,6 +930,10 @@ public final class FantasyStickPlugin extends JavaPlugin { failure -> getLogger().log(Level.WARNING, "Passive potion event intent failed", failure)); speedI.attachEvents(passivePotionEvents); lowLightVision.attachEvents(passivePotionEvents); + pathSpeedEvents = new games.dmg.fantasystick.events.PathSpeedEventBridge( + eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), + failure -> getLogger().log(Level.WARNING, "Road travel event intent failed", failure)); + pathSpeed.attachEvents(pathSpeedEvents); recoveryEvents = new games.dmg.fantasystick.events.RecoveryEventBridge( eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions, failure -> getLogger().log(Level.WARNING, "Recovery event intent failed", failure)); @@ -1117,6 +1133,7 @@ public final class FantasyStickPlugin extends JavaPlugin { getServer().getPluginManager().registerEvents(outOfCombatRecovery, this); outOfCombatRecovery.install(this); speedI.install(this); + pathSpeed.install(this); lowLightVision.install(this); getServer().getPluginManager().registerEvents(books, this); getServer().getPluginManager().registerEvents(gameModes, this); @@ -1198,6 +1215,11 @@ public final class FantasyStickPlugin extends JavaPlugin { if (failure != null) getLogger().log(Level.WARNING, "Previous admin experience intent drain failed", failure); if (replayGrantEvents != null) replayGrantEvents.replay(); }); + var replayRoadEvents = pathSpeedEvents; + pathSpeedAuditDrain.whenComplete((ignored, failure) -> { + if (failure != null) getLogger().log(Level.WARNING, "Previous road event drain failed", failure); + if (replayRoadEvents != null) replayRoadEvents.replay(); + }); var replayPassivePotionEvents = passivePotionEvents; passivePotionAuditDrain.whenComplete((ignored, failure) -> { if (failure != null) getLogger().log(Level.WARNING, "Previous passive potion event drain failed", failure); @@ -1374,6 +1396,7 @@ public final class FantasyStickPlugin extends JavaPlugin { var closingCombatRewardEvents = combatRewardEvents; var closingHealthStealEvents = healthStealEvents; healthStealEvents = null; var closingPassivePotionEvents = passivePotionEvents; passivePotionEvents = null; + var closingPathSpeedEvents = pathSpeedEvents; pathSpeedEvents = null; var closingRecoveryEvents = recoveryEvents; recoveryEvents = null; var closingIdentityAuditEvents = identityAuditEvents; identityAuditEvents = null; var closingAdminRevivalAuditEvents = adminRevivalEvents; adminRevivalEvents = null; @@ -1456,6 +1479,7 @@ public final class FantasyStickPlugin extends JavaPlugin { var closingAdminReviveSettings = adminReviveSettings; adminReviveSettings = null; var closingSwitchDelaySettings = switchDelaySettings; switchDelaySettings = null; var closingRoleplaySettings = roleplaySettings; roleplaySettings = null; + var closingPathSpeedSettings = pathSpeedSettings; pathSpeedSettings = null; var closingPartySettings = partySettings; partySettings = null; var closingPartyEconomySettings = partyEconomySettings; partyEconomySettings = null; var closingGearSettings=gearSettings;gearSettings=null; @@ -1483,6 +1507,7 @@ public final class FantasyStickPlugin extends JavaPlugin { var closingHealthStealSettings = healthStealSettings; healthStealSettings = null; var closingHealthSteal = healthSteal; healthSteal = null; var closingSpeedI = speedI; speedI = null; + var closingPathSpeed = pathSpeed; pathSpeed = null; var closingLowLightVision = lowLightVision; lowLightVision = null; var closingPassiveEngine = passiveEngine; passiveEngine = null; quickerRegeneration = null; quickerRegenerationII = null; passiveRegistry = null; @@ -1523,6 +1548,7 @@ public final class FantasyStickPlugin extends JavaPlugin { if (closingAdminReviveSettings != null) cleanup("admin revival configuration", closingAdminReviveSettings::close); if (closingSwitchDelaySettings != null) cleanup("switch delay configuration", closingSwitchDelaySettings::close); if (closingRoleplaySettings != null) cleanup("roleplay configuration", closingRoleplaySettings::close); + if (closingPathSpeedSettings != null) cleanup("road configuration", closingPathSpeedSettings::close); if (closingPartySettings != null) cleanup("party configuration", closingPartySettings::close); if (closingPartyEconomySettings != null) cleanup("party economy configuration", closingPartyEconomySettings::close); if(closingGearSettings!=null)cleanup("gear configuration",closingGearSettings::close); @@ -1621,6 +1647,8 @@ public final class FantasyStickPlugin extends JavaPlugin { : closingAdminReviveSettings.drained().toCompletableFuture(), closingSwitchDelaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null) : closingSwitchDelaySettings.drained().toCompletableFuture(), + closingPathSpeedSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null) + : closingPathSpeedSettings.drained().toCompletableFuture(), closingRoleplaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null) : closingRoleplaySettings.drained().toCompletableFuture(), closingPartySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null) @@ -1657,6 +1685,7 @@ public final class FantasyStickPlugin extends JavaPlugin { if (closingOutOfCombatRecovery != null) cleanup("out-of-combat recovery", closingOutOfCombatRecovery::close); if (closingPassiveEngine != null) cleanup("passive engine", closingPassiveEngine::close); if (closingHealthSteal != null) cleanup("Health Steal", closingHealthSteal::close); + if (closingPathSpeed != null) cleanup("Dirt Path travel", closingPathSpeed::close); if (closingSpeedI != null) cleanup("Speed I", closingSpeedI::close); if (closingLowLightVision != null) cleanup("Low-Light Vision", closingLowLightVision::close); if (closingVisuals != null) cleanup("session particles", closingVisuals::close); @@ -1743,6 +1772,10 @@ public final class FantasyStickPlugin extends JavaPlugin { return null; }).thenCompose(ignored -> closingGrantEvents.drained()); }); + if (closingPathSpeedEvents != null) cleanup("road travel event intent drain", () -> { + closingPathSpeedEvents.close(); + pathSpeedAuditDrain = closingPathSpeedEvents.drained(); + }); if (closingPassivePotionEvents != null) cleanup("passive potion event intent drain", () -> { closingPassivePotionEvents.close(); passivePotionAuditDrain = closingPassivePotionEvents.drained(); @@ -1944,9 +1977,10 @@ public final class FantasyStickPlugin extends JavaPlugin { adminActionDrain = closingAdminActionEvents.drained(); }); if (closingEventLog != null) cleanup("local events", () -> { - // Preserve synchronous writer shutdown/lock release at disable return. Only the central - // handoff is awaited here; no all-owner sink-acknowledgment guarantee is implied. - var handoff = adminActionDrain; + // Preserve the bounded synchronous writer shutdown/lock release at disable return. + // Fence the added road owner plus central handoff, not an all-legacy-owner guarantee. + var handoff = java.util.concurrent.CompletableFuture.allOf( + adminActionDrain.toCompletableFuture(), pathSpeedAuditDrain.toCompletableFuture()); var writerClosed = new java.util.concurrent.CompletableFuture(); adminActionDrain = writerClosed; Throwable handoffFailure = null; diff --git a/src/main/java/games/dmg/fantasystick/ability/RallyingCry.java b/src/main/java/games/dmg/fantasystick/ability/RallyingCry.java index 4967b8b..66dffbc 100644 --- a/src/main/java/games/dmg/fantasystick/ability/RallyingCry.java +++ b/src/main/java/games/dmg/fantasystick/ability/RallyingCry.java @@ -54,7 +54,9 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable { } for (var entry : eligible.entrySet()) { if (!recipients.containsKey(entry.getKey())) { - var benefit = new Benefit(this, entry.getValue()); recipients.put(entry.getKey(), benefit); + var benefit = new Benefit(this, entry.getValue()); + if (road != null && !eligible(entry.getValue())) benefit.close(); + else recipients.put(entry.getKey(), benefit); } } } @@ -96,7 +98,10 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable { final UUID application; boolean closed; Benefit(Area area, Player player) { this.area = area; this.player = player; - speed = speed(player, area.settings.speedAmplifier()); + speed = speed(player, area.settings.speedAmplifier(), () -> !closed && !area.closed && area.eligible(player)); + if (road != null && !area.eligible(player)) { + closeResource(speed); bonus = () -> { }; application = null; return; + } 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(); @@ -118,8 +123,11 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable { } private final class SpeedGrant implements AutoCloseable { - final SpeedState state; final long sequence; final int amplifier; boolean closed; - SpeedGrant(SpeedState state, int amplifier) { this.state = state; sequence = ++speedSequences; this.amplifier = amplifier; } + final SpeedState state; final long sequence; final int amplifier; + final java.util.function.BooleanSupplier eligible; boolean closed; + SpeedGrant(SpeedState state, int amplifier, java.util.function.BooleanSupplier eligible) { + this.state = state; sequence = ++speedSequences; this.amplifier = amplifier; this.eligible = eligible; + } @Override public void close() { if (closed) return; closed = true; state.grants.remove(this); state.refresh(); } } private final class SpeedState { @@ -131,7 +139,7 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable { catch (RuntimeException unavailable) { return; } if (owned != null && !same(current, owned)) owned = null; SpeedGrant strongest = null; - for (var grant : List.copyOf(grants)) if (!grant.closed && (strongest == null + for (var grant : List.copyOf(grants)) if (!grant.closed && (road == null || grant.eligible.getAsBoolean()) && (strongest == null || grant.amplifier > strongest.amplifier || grant.amplifier == strongest.amplifier && grant.sequence < strongest.sequence)) strongest = grant; if (strongest == null) { @@ -142,6 +150,11 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable { if (!removeOwned()) return; current = null; } + if (road != null && road.hasRoadLease(player)) { + if (!road.releaseForPersistent(player)) return; + if (closed || strongest.closed || !strongest.eligible.getAsBoolean()) return; + current = null; + } if (current != null && current.getAmplifier() >= strongest.amplifier) return; var effect = new PotionEffect(PotionEffectType.SPEED, PotionEffect.INFINITE_DURATION, strongest.amplifier, false, false, true); @@ -166,6 +179,11 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable { private long sequences; private long speedSequences; private org.bukkit.scheduler.BukkitTask sweepTask; private boolean closed; + private games.dmg.fantasystick.passive.PathSpeed road; + public void attachRoad(games.dmg.fantasystick.passive.PathSpeed value) { + if (closed || road != null || !areas.isEmpty()) throw new IllegalStateException("Road coordination already attached or Rally started"); + road = Objects.requireNonNull(value); + } public RallyingCry(Settings settings, EquipmentSessions sessions, PartyRoster parties, OutgoingDamageBonus bonuses) { this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions); @@ -211,9 +229,9 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable { }); } - private AutoCloseable speed(Player player, int amplifier) { + private AutoCloseable speed(Player player, int amplifier, java.util.function.BooleanSupplier eligible) { var state = speeds.computeIfAbsent(player.getUniqueId(), ignored -> new SpeedState(player)); - var grant = new SpeedGrant(state, amplifier); state.grants.add(grant); state.refresh(); return grant; + var grant = new SpeedGrant(state, amplifier, eligible); state.grants.add(grant); state.refresh(); return grant; } private void breakArea(Area area) { area.endReason = "eligibility_changed"; area.close(); diff --git a/src/main/java/games/dmg/fantasystick/book/CharacterBookController.java b/src/main/java/games/dmg/fantasystick/book/CharacterBookController.java index e8dfcf2..7cefb48 100644 --- a/src/main/java/games/dmg/fantasystick/book/CharacterBookController.java +++ b/src/main/java/games/dmg/fantasystick/book/CharacterBookController.java @@ -623,6 +623,7 @@ public final class CharacterBookController implements Consumer, Au ? partyControls.pages(input) : savedState ? partyControls.readOnlyCharacterPages(record.id()) : partyControls.readOnlyPages(subject.getUniqueId())); + if (end == allOverview.size() && !savedState) pages.addAll(roadGuidancePages()); meta.pages(pages); item.setItemMeta(meta); return item; @@ -631,6 +632,16 @@ public final class CharacterBookController implements Consumer, Au private java.util.List inventoryPages(UUID character,ControlInput input){var directory=fishing;var rare=rareInventory;if(directory==null&&rare==null)return java.util.List.of();var values=directory==null?java.util.List.of():directory.inventory(character);var trophies=directory==null?java.util.List.of():directory.trophies(character);var goods=rare==null?java.util.List.of():rare.inventory(character);RareView view=input==null?null:new RareView(UUID.randomUUID().toString(),input,goods.stream().filter(games.dmg.fantasystick.shop.RareInventoryDirectory.Item::available).collect(java.util.stream.Collectors.toUnmodifiableMap(games.dmg.fantasystick.shop.RareInventoryDirectory.Item::id,games.dmg.fantasystick.shop.RareInventoryDirectory.Item::quantity)));if(view!=null&&!view.quantities().isEmpty())rareViews.put(input.session().player(),view);var builder=new PageBuilder();builder.text("Inventory");builder.blank();if(values.isEmpty()&&trophies.isEmpty()&&goods.isEmpty())builder.text("No items.");else{int shown=0;for(var value:values){if(shown++>=256)break;builder.text(value.name()+": "+value.amount());builder.text("Fishing origin: "+value.site());}for(var trophy:trophies){if(shown++>=256)break;builder.text(trophy.name()+": 1");builder.text("Trophy origin: "+trophy.origin());}for(var good:goods){if(shown++>=256)break;boolean phoenix=games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN.id().equals(good.id());builder.inline(good.name()+": "+good.quantity(),view!=null&&good.available()&&!phoenix?rareButton(view,good.id()):null);if(phoenix&&good.available())builder.text("Use Phoenix Down while downed.");if(!good.available())builder.text("Unavailable item type");}if(values.size()+trophies.size()+goods.size()>256)builder.text("More items are retained.");}return builder.pages();} private static Component rareButton(RareView view,String product){return Component.text("[Use]",net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN).clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(RARE_USE,net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\""+view.nonce()+":"+product+"\"")));} + private java.util.List roadGuidancePages() { + var builder = new PageBuilder(); + builder.text("Dirt Path travel"); + builder.text("Your active living character automatically gains native Speed I on Dirt Paths: no points or passive slots."); + builder.text("Stay grounded and on foot. Jumping, swimming, flying, gliding, mounting and downing stop road grants."); + builder.text("Equal Speed does not stack; other Speed sources are preserved. Administrator-enabled road leases last 1–200 native ticks (default 20), renewed while eligible."); + builder.text("Leaving requests owned cleanup. A refused or interrupted cleanup can leave the short finite lease until native expiry; offline ticks are not wall-clock expiry."); + return builder.pages(); + } + private Component gearPage(CharacterRecord record){return Component.text("Gear\n\nHead: Cosmetic\nChestplate: "+tier(record.gear().chestplate())+"\nLeggings: "+tier(record.gear().leggings())+"\nBoots: "+tier(record.gear().boots()));} private static String tier(games.dmg.fantasystick.persistence.GearTier tier){String value=tier.name().toLowerCase(java.util.Locale.ROOT);return Character.toUpperCase(value.charAt(0))+value.substring(1);} diff --git a/src/main/java/games/dmg/fantasystick/events/PathSpeedEventBridge.java b/src/main/java/games/dmg/fantasystick/events/PathSpeedEventBridge.java new file mode 100644 index 0000000..23de187 --- /dev/null +++ b/src/main/java/games/dmg/fantasystick/events/PathSpeedEventBridge.java @@ -0,0 +1,68 @@ +package games.dmg.fantasystick.events; + +import games.dmg.fantasystick.session.EquipmentSessions; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletionStage; +import java.util.function.Consumer; +import java.util.function.Function; +import org.bukkit.entity.Player; + +/** Immutable real road-effect observations; export/replay has no gameplay authority. */ +public final class PathSpeedEventBridge implements AutoCloseable { + public record Application(UUID id, EventInput.Context context, Instant startedAt, int leaseTicks) { } + private final Clock clock; + private final FeatureEventOutbox outbox; + public PathSpeedEventBridge(Function> sink, + Clock clock, Path directory, Consumer failures) { + this.clock = Objects.requireNonNull(clock); + outbox = new FeatureEventOutbox(directory, "path-speed-event-intents", + java.util.Set.of("effect.applied", "effect.ended"), sink, failures); + } + public Application applied(Player player, EquipmentSessions.Active active, String name, int ticks) { + try { + var context = new EventInput.Context(player.getUniqueId(), player.getName(), active.character(), name, + active.session(), location(player)); + var application = new Application(UUID.randomUUID(), context, clock.instant(), ticks); + emit("effect.applied", application, context, "eligible_dirt_path_support", "owned_layer_applied"); + return application; + } catch (RuntimeException unavailable) { return null; } + } + public void ended(Application application, Player player, String reason, String disposition) { + if (application == null) return; + try { + var before = application.context(); + var context = new EventInput.Context(before.playerId(), before.playerName(), before.characterId(), + before.characterName(), before.sessionId(), location(player)); + emit("effect.ended", application, context, reason, disposition); + } catch (RuntimeException unavailable) { /* Never retry or authorize native effects from logging. */ } + } + private void emit(String type, Application application, EventInput.Context context, String reason, String disposition) { + try { + var values = new java.util.LinkedHashMap(); + values.put("feature_id", "dirt_path_speed"); values.put("application_id", application.id().toString()); + values.put("effect_id", application.id().toString()); values.put("owner_character_id", context.characterId().toString()); + values.put("owner_session_id", context.sessionId().toString()); values.put("target_player_id", context.playerId().toString()); + values.put("effect_type", "minecraft:speed"); values.put("amplifier", 0); values.put("lease_ticks", application.leaseTicks()); + values.put("started_at", application.startedAt().toString()); values.put("reason", reason); + values.put("native_disposition", disposition); values.put("native_publication", "acknowledged"); + values.put("location_capture_quality", context.location() == null ? "unavailable" : "exact"); + UUID id = UUID.nameUUIDFromBytes(("fantasystick:path-speed:" + application.id() + ":" + type).getBytes(StandardCharsets.UTF_8)); + outbox.accepted(new EventInput(id, application.id(), clock.instant(), type, context, Map.copyOf(values))); + } catch (RuntimeException unavailable) { /* Native outcome stands even if audit admission fails. */ } + } + private EventInput.Location location(Player player) { + var location = player.getLocation(); + if (location == null || location.getWorld() == null) return null; + return new EventInput.Location(location.getWorld().getUID(), location.getWorld().getName(), + location.getX(), location.getY(), location.getZ(), clock.instant(), "exact"); + } + public void replay() { outbox.replay(); } + public CompletionStage drained() { return outbox.drained(); } + @Override public void close() { outbox.close(); } +} diff --git a/src/main/java/games/dmg/fantasystick/passive/PathSpeed.java b/src/main/java/games/dmg/fantasystick/passive/PathSpeed.java new file mode 100644 index 0000000..f71b372 --- /dev/null +++ b/src/main/java/games/dmg/fantasystick/passive/PathSpeed.java @@ -0,0 +1,271 @@ +package games.dmg.fantasystick.passive; + +import games.dmg.fantasystick.session.EquipmentSessions; +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.effect.MobEffectInstance; +import net.minecraft.world.level.EmptyBlockGetter; +import net.minecraft.world.level.block.Blocks; +import org.bukkit.craftbukkit.entity.CraftPlayer; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityPotionEffectEvent; +import org.bukkit.potion.PotionEffect; +import org.bukkit.potion.PotionEffectType; +import org.bukkit.scheduler.BukkitTask; + +/** Automatic finite road travel, independent of learned passive slots and rewards. */ +public final class PathSpeed implements Listener, AutoCloseable { + private static final int CHECKS = 256; + public record Settings(boolean enabled, int leaseTicks) { + public Settings { if (leaseTicks < 1 || leaseTicks > 200) throw new IllegalArgumentException("Path lease must be 1–200 ticks"); } + public static Settings defaults() { return new Settings(true, 20); } + } + private final EquipmentSessions sessions; + private final Map states = new HashMap<>(); + private final ArrayDeque rotation = new ArrayDeque<>(); + private volatile Settings settings; + private BukkitTask task; + private org.bukkit.plugin.Plugin plugin; + private boolean closed; + private boolean closeScheduled; + private long generation; + private games.dmg.fantasystick.events.PathSpeedEventBridge events; + public void attachEvents(games.dmg.fantasystick.events.PathSpeedEventBridge bridge) { + if (closed || events != null || !states.isEmpty()) throw new IllegalStateException("Road events already attached or evaluation started"); + events = Objects.requireNonNull(bridge); + } + public Settings settings() { return settings; } + + public PathSpeed(EquipmentSessions sessions, Settings settings) { + this.sessions = Objects.requireNonNull(sessions); this.settings = Objects.requireNonNull(settings); + } + public void configure(Settings value) { settings = Objects.requireNonNull(value); if (!closed) sweep(); } + public void install(org.bukkit.plugin.Plugin owner) { + if (closed || plugin != null) throw new IllegalStateException("Road travel already installed or closed"); + NativePotionState.prepare(); plugin = Objects.requireNonNull(owner); + owner.getServer().getPluginManager().registerEvents(this, owner); + task = owner.getServer().getScheduler().runTaskTimer(owner, this::sweep, 1, 1); + } + public void sweep() { + if (closed || NativePotionState.dispatching()) return; + generation++; + for (var snapshot : sessions.rotateActiveCharacters(CHECKS)) { + var active = snapshot.session(); var state = states.get(active.player()); + if (state != null && !state.active.equals(active)) { + state.refresh(); if (states.get(active.player()) == state) continue; + state = null; + } + if (state == null) { + var player = sessions.currentPlayer(active).orElse(null); + if (player == null) continue; + state = new State(active, player, snapshot.record().name()); states.put(active.player(), state); rotation.addLast(state); + } + state.refresh(); + } + int count = Math.min(CHECKS, rotation.size()); + for (int i = 0; i < count; i++) { + var state = rotation.removeFirst(); + if (states.get(state.active.player()) != state) continue; + if (state.processedAt != generation) state.refresh(); + if (states.get(state.active.player()) == state) rotation.addLast(state); + } + } + private boolean eligible(State state) { + if (closed || !settings.enabled() || !sessions.current(state.active)) return false; + var record = sessions.currentRecord(state.active).orElse(null); + if (record == null || record.dead()) return false; + var player = state.player; + if (player.isDead() || player.getHealth() <= 0 || !player.isOnGround() || player.isInsideVehicle() + || player.isSwimming() || player.isFlying() || player.isGliding()) return false; + var velocity = player.getVelocity(); + if (velocity == null || !Double.isFinite(velocity.getY()) || velocity.getY() > 0.000001) return false; + var location = player.getLocation(); + if (location == null || !Double.isFinite(location.getX()) || !Double.isFinite(location.getY()) + || !Double.isFinite(location.getZ()) || Math.abs(location.getX()) > 30_000_000 + || Math.abs(location.getZ()) > 30_000_000 || Math.abs(location.getY()) > Integer.MAX_VALUE - 1.0) return false; + if (!(player instanceof CraftPlayer craft)) return false; + var pos = BlockPos.containing(location.getX(), location.getY() - 0.000001, location.getZ()); + // Never request a chunk load. The pinned native path collision shape provides its lowered top. + var block = craft.getHandle().level().getBlockStateIfLoaded(pos); + if (block == null || !block.is(Blocks.DIRT_PATH)) return false; + var shape = block.getCollisionShape(EmptyBlockGetter.INSTANCE, pos); + return !shape.isEmpty() && Math.abs(location.getY() - pos.getY() - shape.max(Direction.Axis.Y)) <= 0.000001; + } + private final class State { + final EquipmentSessions.Active active; + final Player player; + MobEffectInstance owned; + int capturedTicks; + int capturedDuration; + long processedAt; + int mutating; + private static final int MAX_MUTATION_PROOFS = 32; + final java.util.List foreignEvents = new java.util.ArrayList<>(); + boolean foreignUncertain; + final java.util.List mutationEvents = new java.util.ArrayList<>(); + boolean mutationUncertain; + void observeForeign(EntityPotionEffectEvent event) { + if (foreignEvents.size() < MAX_MUTATION_PROOFS) foreignEvents.add(event); + else foreignUncertain = true; + } + void beginMutation() { mutating++; mutationEvents.clear(); mutationUncertain = false; } + void finishMutation(boolean accepted) { + mutating--; + long finalDecisions = mutationEvents.stream().filter(event -> !event.isCancelled()).count(); + // Only a returned native acceptance plus one completed decision proves our sole mutation. + if (!(accepted && finalDecisions == 1 && !mutationUncertain)) { + for (var event : mutationEvents) observeForeign(event); + if (mutationUncertain || accepted) foreignUncertain = true; + } + mutationEvents.clear(); + } + boolean foreignChange(MobEffectInstance current) { + if (foreignUncertain) return true; + for (var event : foreignEvents) { + if (event.isCancelled()) continue; // Revalidate the final event after complete dispatch. + if (event.getAction() == EntityPotionEffectEvent.Action.CHANGED && event.isOverride()) return true; + if (event.getAction() == EntityPotionEffectEvent.Action.REMOVED && current != owned) return true; + if (event.getAction() == EntityPotionEffectEvent.Action.ADDED) return true; + } + return false; + } + void clearForeignProof() { foreignEvents.clear(); foreignUncertain = false; } + final String capturedName; + games.dmg.fantasystick.events.PathSpeedEventBridge.Application application; + boolean applicationCaptureAttempted; + State(EquipmentSessions.Active active, Player player, String name) { this.active = active; this.player = player; capturedName = name; } + void end(String reason, String disposition) { + if (events != null && application != null) events.ended(application, player, reason, disposition); + application = null; applicationCaptureAttempted = false; + } + String endReason() { + if (closed) return "plugin_shutdown"; + if (!settings.enabled()) return "road_policy_disabled"; + if (!sessions.current(active)) return "session_no_longer_current"; + return "road_eligibility_ended"; + } + boolean quiet() { return mutating == 0 && !NativePotionState.dispatching() && NativePotionState.quiet(player); } + boolean matches(MobEffectInstance current) { + long age = Integer.toUnsignedLong(player.getTicksLived() - capturedTicks); + // A zero-duration instance whose native expiry was refused still requires owned cleanup. + return current != null && current == owned && !foreignChange(current) && current.hiddenEffect == null + && current.getAmplifier() == 0 && !current.isAmbient() && !current.isVisible() && current.showIcon() + && current.getDuration() == Math.max(0L, capturedDuration - age); + } + void refresh() { + processedAt = generation; + try { + if (!quiet()) return; + var current = NativePotionState.identity(player, PotionEffectType.SPEED); + boolean allowed = eligible(this); + if (owned != null && !matches(current)) { + long age = Integer.toUnsignedLong(player.getTicksLived() - capturedTicks); + if (!(allowed && current == null && !foreignChange(current) && age >= capturedDuration)) + end("native_layer_replaced_or_removed", current == null ? "native_layer_absent" : "different_native_layer_preserved"); + owned = null; clearForeignProof(); + } + if (!allowed) { + if (owned != null && matches(current)) { + beginMutation(); + try { player.removePotionEffect(PotionEffectType.SPEED); } + finally { finishMutation(false); } + current = NativePotionState.identity(player, PotionEffectType.SPEED); + if (current == owned) return; // Final cancellation/uncertain removal is not cleanup. + owned = null; + end(endReason(), current == null ? "owned_layer_removed" : "different_native_layer_preserved"); + } + if (application != null) end(endReason(), "native_layer_absent"); + states.remove(active.player(), this); return; + } + if (current != null && owned == null) return; // Equal/stronger/foreign layers are never ours. + var requested = new PotionEffect(PotionEffectType.SPEED, settings.leaseTicks(), 0, false, false, true); + clearForeignProof(); beginMutation(); + boolean accepted = false; + try { accepted = player.addPotionEffect(requested, true); } + finally { finishMutation(accepted); } + if (!accepted || !quiet()) return; + var installed = NativePotionState.identity(player, PotionEffectType.SPEED); + if (installed == null || installed.hiddenEffect != null || foreignChange(installed) + || installed.getAmplifier() != 0 || installed.getDuration() != requested.getDuration() + || installed.isAmbient() || installed.isVisible() || !installed.showIcon()) return; + owned = installed; capturedDuration = requested.getDuration(); capturedTicks = player.getTicksLived(); + // Native dispatch can exit/down/move the holder or close this owner before admission returns. + if (!eligible(this)) { refresh(); return; } + if (events != null && !applicationCaptureAttempted) { + applicationCaptureAttempted = true; + application = events.applied(player, active, + sessions.currentRecord(active).map(record -> record.name()).orElse(capturedName), capturedDuration); + } + } catch (RuntimeException unavailable) { /* Failed native reads/calls retain existing cleanup responsibility. */ } + } + } + public boolean hasRoadLease(Player player) { + var state = states.get(player.getUniqueId()); return state != null && state.player == player && state.owned != null; + } + + /** Internal equal-source handoff; only a proved road layer may be retired for a persistent source. */ + public boolean releaseForPersistent(Player player) { + var state = states.get(player.getUniqueId()); + if (state == null || state.player != player) return false; + try { + if (!state.quiet() || !state.matches(NativePotionState.identity(player, PotionEffectType.SPEED))) return false; + state.beginMutation(); + try { player.removePotionEffect(PotionEffectType.SPEED); } + finally { state.finishMutation(false); } + if (NativePotionState.identity(player, PotionEffectType.SPEED) != null) return false; + state.owned = null; state.clearForeignProof(); + state.end("persistent_speed_handoff", "owned_layer_removed"); return true; + } catch (RuntimeException unavailable) { return false; } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void mutation(EntityPotionEffectEvent event) { + if (event.getModifiedType() != PotionEffectType.SPEED) return; + var state = states.get(event.getEntity().getUniqueId()); + if (state == null) return; + if (state.mutating != 0) { + if (state.mutationEvents.size() < State.MAX_MUTATION_PROOFS) state.mutationEvents.add(event); + else state.mutationUncertain = true; + return; // Capture cancelled and nested decisions too; dispatch order is not source identity. + } + if (event.isCancelled()) return; + if (event.getCause() == EntityPotionEffectEvent.Cause.EXPIRATION + && event.getAction() == EntityPotionEffectEvent.Action.REMOVED) { + try { + if (state.owned != null && state.owned == NativePotionState.identity(state.player, PotionEffectType.SPEED)) return; + } catch (RuntimeException unavailable) { /* Unknown expiry cannot acquire renewal/removal authority. */ } + } + state.observeForeign(event); // Final outcomes are checked later, outside dispatch. + } + private void closingAttempt() { + generation++; + int count = Math.min(CHECKS, rotation.size()); + for (int index = 0; index < count; index++) { + var state = rotation.removeFirst(); + if (states.get(state.active.player()) != state) continue; + state.refresh(); + if (states.get(state.active.player()) == state) rotation.addLast(state); + } + } + private void finishClose() { + closingAttempt(); + if (!states.isEmpty() && !closeScheduled && plugin != null && plugin.isEnabled()) { + closeScheduled = true; + try { plugin.getServer().getScheduler().runTask(plugin, this::closingAttempt); } + catch (RuntimeException unavailable) { /* Finite/native-refusal fallback; no population-wide or endless retry. */ } + } + } + @Override public void close() { + if (closed) return; closed = true; + if (task != null) task.cancel(); + finishClose(); + } +} diff --git a/src/main/java/games/dmg/fantasystick/passive/PersistentPotionEffect.java b/src/main/java/games/dmg/fantasystick/passive/PersistentPotionEffect.java index 0f582ee..6f6effa 100644 --- a/src/main/java/games/dmg/fantasystick/passive/PersistentPotionEffect.java +++ b/src/main/java/games/dmg/fantasystick/passive/PersistentPotionEffect.java @@ -49,6 +49,11 @@ final class PersistentPotionEffect implements AutoCloseable { owned = null; nativeOwned = null; displaced = null; } if (!eligible()) { remove(endReason()); return; } + if (road != null && current != null && !current.isInfinite() && current.getAmplifier() == amplifier + && road.releaseForPersistent(player)) { + current = null; identity = null; + if (!eligible()) { remove(endReason()); return; } + } if (current != null && (!replaceWeaker || current.getAmplifier() >= amplifier)) return; if (!quiet()) return; var effect = new PotionEffect(type, PotionEffect.INFINITE_DURATION, amplifier, false, false, true); @@ -147,6 +152,12 @@ final class PersistentPotionEffect implements AutoCloseable { private boolean closing; private boolean closeScheduled; private PassivePotionEventBridge events; + private PathSpeed road; + + void attachRoad(PathSpeed value) { + if (closed || road != null || !states.isEmpty()) throw new IllegalStateException("Road coordination already attached or started"); + road = Objects.requireNonNull(value); + } void attachEvents(PassivePotionEventBridge bridge) { if (closed || events != null || !states.isEmpty()) throw new IllegalStateException("Passive potion events already attached or evaluation started"); diff --git a/src/main/java/games/dmg/fantasystick/passive/SpeedI.java b/src/main/java/games/dmg/fantasystick/passive/SpeedI.java index 5e928ae..22f54fb 100644 --- a/src/main/java/games/dmg/fantasystick/passive/SpeedI.java +++ b/src/main/java/games/dmg/fantasystick/passive/SpeedI.java @@ -23,6 +23,7 @@ public final class SpeedI implements AutoCloseable { "Does not stack with equal Speed I and preserves stronger unrelated Speed effects.")); } + public void attachRoad(PathSpeed road) { effect.attachRoad(road); } public void attachEvents(games.dmg.fantasystick.events.PassivePotionEventBridge bridge) { effect.attachEvents(bridge); } public void install(org.bukkit.plugin.Plugin plugin) { effect.install(plugin); } public void sweep() { effect.sweep(); } diff --git a/src/main/java/games/dmg/fantasystick/persistence/PathSpeedSettings.java b/src/main/java/games/dmg/fantasystick/persistence/PathSpeedSettings.java new file mode 100644 index 0000000..b2f1ec1 --- /dev/null +++ b/src/main/java/games/dmg/fantasystick/persistence/PathSpeedSettings.java @@ -0,0 +1,150 @@ +package games.dmg.fantasystick.persistence; + +import games.dmg.fantasystick.passive.PathSpeed; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.OverlappingFileLockException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; + +/** Serialized durable road configuration; accepted queued writes compose from worker-owned state. */ +public final class PathSpeedSettings implements AutoCloseable { + public static final String ENABLED = "path-speed-enabled"; + public static final String LEASE = "path-speed-lease-ticks"; + private static final int MAX_BYTES = 1_048_576; + @FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; } + public record Update(PathSpeed.Settings settings, long sequence, String key) { + public Update { Objects.requireNonNull(settings); if (!keys().contains(key) || sequence < 1) throw new IllegalArgumentException("Invalid saved road update"); } + } + private final Path directory; + private final Replacement replacement; + private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor( + task -> new Thread(task, "fantasy-path-speed-configuration")); + private volatile PathSpeed.Settings effective; + private PathSpeed.Settings persisted; + private long savedSequence; + private final Map publishedSequences = new java.util.HashMap<>(); + private boolean closed; + private int pending; + private final CompletableFuture drain = new CompletableFuture<>(); + + public PathSpeedSettings(Path directory, PathSpeed.Settings initial) { + this(directory, initial, (from, to) -> Files.move(from, to, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)); + } + PathSpeedSettings(Path directory, PathSpeed.Settings initial, Replacement replacement) { + this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement); + effective = Objects.requireNonNull(initial); persisted = initial; + } + public static List keys() { return List.of(ENABLED, LEASE); } + public static String defaultValue(String key) { return value(PathSpeed.Settings.defaults(), key); } + private static String value(PathSpeed.Settings settings, String key) { + return switch (key) { + case ENABLED -> Boolean.toString(settings.enabled()); + case LEASE -> Integer.toString(settings.leaseTicks()); + default -> throw new IllegalArgumentException("Unknown road setting"); + }; + } + public String value(String key) { return value(effective, key); } + public PathSpeed.Settings settings() { return effective; } + public static PathSpeed.Settings load(Object enabled, Object ticks) { + if (enabled != null && !(enabled instanceof Boolean)) throw new IllegalArgumentException(ENABLED + " must be true or false"); + if (ticks != null && !(ticks instanceof Integer || ticks instanceof Long)) throw new IllegalArgumentException(LEASE + " must be an integer from 1–200"); + long lease = ticks == null ? 20 : ((Number) ticks).longValue(); + if (lease < 1 || lease > 200) throw new IllegalArgumentException(LEASE + " must be an integer from 1–200"); + return new PathSpeed.Settings(enabled == null || (Boolean) enabled, (int) lease); + } + private static Object validated(String key, String raw) { + if (ENABLED.equals(key)) { + if (!"true".equals(raw) && !"false".equals(raw)) throw new IllegalArgumentException(ENABLED + " must be true or false"); + return Boolean.valueOf(raw); + } + if (!LEASE.equals(key)) throw new IllegalArgumentException("Unknown road setting"); + try { + if (raw == null || !raw.matches("[0-9]+")) throw new NumberFormatException(); + int ticks = Integer.parseInt(raw); + if (ticks < 1 || ticks > 200) throw new NumberFormatException(); + return ticks; + } catch (NumberFormatException invalid) { throw new IllegalArgumentException(LEASE + " must be an integer from 1–200"); } + } + public CompletionStage set(String key, String raw) { + Object accepted = validated(key, raw); + synchronized (this) { + if (closed) return CompletableFuture.failedStage(new IOException("Road configuration store closed")); + pending++; + } + try { + return CompletableFuture.supplyAsync(() -> { + try { + var next = ENABLED.equals(key) ? new PathSpeed.Settings((Boolean) accepted, persisted.leaseTicks()) + : new PathSpeed.Settings(persisted.enabled(), (Integer) accepted); + save(key, accepted); persisted = next; return new Update(next, ++savedSequence, key); + } catch (IOException | RuntimeException failure) { + throw new java.util.concurrent.CompletionException(new IOException("Road configuration save failed; effective policy unchanged")); + } finally { finished(); } + }, worker).minimalCompletionStage(); + } catch (RejectedExecutionException rejected) { finished(); return CompletableFuture.failedStage(new IOException("Road configuration store closed")); } + } + public synchronized boolean publish(Update update) { + Objects.requireNonNull(update); + if (closed || update.sequence() <= publishedSequences.getOrDefault(update.key(), 0L)) return false; + // A saved composite is disk evidence, not authority to publish another callback's key. + effective = ENABLED.equals(update.key()) ? new PathSpeed.Settings(update.settings().enabled(), effective.leaseTicks()) + : new PathSpeed.Settings(effective.enabled(), update.settings().leaseTicks()); + publishedSequences.put(update.key(), update.sequence()); return true; + } + private void save(String key, Object accepted) throws IOException { + Files.createDirectories(directory); + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration directory"); + var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock"); + if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration lock"); + try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS); + var lock = channel.tryLock()) { + if (lock == null) throw new IOException("Configuration busy"); + var options = new LoaderOptions(); options.setAllowDuplicateKeys(false); options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES); + var yaml = new Yaml(new SafeConstructor(options)); Map values = new LinkedHashMap<>(); + if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) { + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration file"); + byte[] bytes; + try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); } + if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large"); + Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString()); + if (loaded != null) { + if (!(loaded instanceof Map map)) throw new IOException("Invalid configuration root"); + for (var entry : map.entrySet()) { + if (!(entry.getKey() instanceof String name)) throw new IOException("Invalid configuration key"); + values.put(name, entry.getValue()); + } + } + } + values.put(key, accepted); byte[] bytes = yaml.dump(values).getBytes(StandardCharsets.UTF_8); + if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large"); + var temporary = Files.createTempFile(directory, ".config-", ".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); + } + replacement.move(temporary, file); + try (var parent = FileChannel.open(directory, StandardOpenOption.READ)) { parent.force(true); } + } finally { Files.deleteIfExists(temporary); } + } catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); } + } + private synchronized void finished() { pending--; if (closed && pending == 0) drain.complete(null); } + public CompletionStage drained() { return drain.minimalCompletionStage(); } + @Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drain.complete(null); } +} diff --git a/src/main/java/games/dmg/fantasystick/session/EquipmentAdminCommand.java b/src/main/java/games/dmg/fantasystick/session/EquipmentAdminCommand.java index ae7c9f2..f5e523e 100644 --- a/src/main/java/games/dmg/fantasystick/session/EquipmentAdminCommand.java +++ b/src/main/java/games/dmg/fantasystick/session/EquipmentAdminCommand.java @@ -107,6 +107,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable { private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings; private games.dmg.fantasystick.events.SwitchConfigEventBridge switchConfigEvents; private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings; + private games.dmg.fantasystick.persistence.PathSpeedSettings pathSpeedSettings; + private games.dmg.fantasystick.passive.PathSpeed pathSpeed; + public void attachPathSpeed(games.dmg.fantasystick.persistence.PathSpeedSettings settings, games.dmg.fantasystick.passive.PathSpeed speed) { + if (closed || pathSpeedSettings != null) throw new IllegalStateException("Road settings already attached or command closed"); + pathSpeedSettings = Objects.requireNonNull(settings); pathSpeed = Objects.requireNonNull(speed); + } private games.dmg.fantasystick.events.RoleplayAvailabilityEventBridge roleplayAvailabilityEvents; private games.dmg.fantasystick.persistence.PartySettings partySettings; private games.dmg.fantasystick.events.PartyCapacityEventBridge partyCapacityEvents; @@ -1340,6 +1346,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable { if (args[1].equals("adventure-mode")) { adventureConfiguration(sender, args); return; } if (args[1].equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY)) { switchDelayConfiguration(sender,args); return; } if (args[1].equals(games.dmg.fantasystick.persistence.RoleplaySettings.KEY)) { roleplayConfiguration(sender,args); return; } + if (pathSpeedSettings != null && games.dmg.fantasystick.persistence.PathSpeedSettings.keys().contains(args[1])) { pathConfiguration(sender,args); return; } if (args[1].equals(games.dmg.fantasystick.persistence.PartySettings.KEY)) { partyConfiguration(sender,args); return; } if (partyEconomySettings != null && games.dmg.fantasystick.persistence.PartyEconomySettings.keys().contains(args[1])) { partyEconomyConfiguration(sender,args); return; } if(gearSettings!=null&&games.dmg.fantasystick.persistence.GearSettings.keys().contains(args[1])){gearConfiguration(sender,args);return;} @@ -2532,6 +2539,35 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable { }); } + private void pathConfiguration(CommandSender sender, String[] args) { + var action = adminAudit == null ? null : adminAudit.current(); + String key = args[1]; var before = pathSpeedSettings.value(key); + if (args.length == 2) { + adminConfigRead(action, key, before, null); sender.sendMessage(key + "=" + before); return; + } + java.util.concurrent.CompletionStage operation; + try { operation = pathSpeedSettings.set(key, args[2]); } + catch (IllegalArgumentException invalid) { adminConfigRefused(action, key); sender.sendMessage(invalid.getMessage()); return; } + operation.whenComplete((saved, failure) -> { + if (failure != null) report(null, "config-path-speed", failure); + if (closed) { adminConfigInterrupted(action, key, failure); return; } + try { nextTick.accept(() -> { + if (closed) { adminConfigInterrupted(action, key, failure); return; } + if (!sender.hasPermission(PERMISSION) || sender instanceof org.bukkit.entity.Player player && !player.isOnline()) { + adminConfigInterrupted(action, key, failure); return; + } + boolean applied = failure == null && pathSpeedSettings.publish(saved); + if (applied) pathSpeed.configure(pathSpeedSettings.settings()); + adminConfigSaved(action, key, before, pathSpeedSettings.value(key), failure, applied, null); + if (!sender.hasPermission(PERMISSION) || sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return; + if (failure != null) sender.sendMessage("Road configuration save failed; effective policy unchanged."); + else if (!applied) sender.sendMessage("Saved request superseded; effective " + key + "=" + pathSpeedSettings.value(key)); + else sender.sendMessage("Saved " + key + "=" + pathSpeedSettings.value(key) + + "; subsequent road grants use this policy; existing finite leases keep their remaining budget."); + }); } catch (RuntimeException rejected) { report(null, "config-response", rejected); adminConfigInterrupted(action, key, failure); } + }); + } + private void roleplayConfiguration(CommandSender sender,String[] args){ var configAction = adminAudit == null ? null : adminAudit.current(); UUID configReadOperation = UUID.randomUUID(); @@ -2782,6 +2818,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable { keys.addAll(games.dmg.fantasystick.persistence.SanctuarySettings.keys()); keys.addAll(games.dmg.fantasystick.persistence.WarBannerSettings.keys()); keys.addAll(games.dmg.fantasystick.persistence.MeteorSettings.keys()); + keys.addAll(games.dmg.fantasystick.persistence.PathSpeedSettings.keys()); return java.util.List.copyOf(keys); } @@ -2858,6 +2895,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable { return matching(configurationKeys(), args[1]); } if (args.length == 3 && args[0].equals("config")) { + if (pathSpeedSettings != null && games.dmg.fantasystick.persistence.PathSpeedSettings.keys().contains(args[1])) { + if (args[1].equals(games.dmg.fantasystick.persistence.PathSpeedSettings.ENABLED)) return matching(List.of("false", "true"), args[2]); + String current = pathSpeedSettings.value(args[1]); String defaults = games.dmg.fantasystick.persistence.PathSpeedSettings.defaultValue(args[1]); + return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]); + } if (args[1].equals("adventure-mode")) return matching(List.of("false", "true"), args[2]); if(args[1].equals(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY)&&adminReviveSettings!=null){String current=adminReviveSettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.AdminReviveSettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.AdminReviveSettings.DEFAULT),args[2]);} if(args[1].equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY)&&switchDelaySettings!=null){String current=switchDelaySettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.SwitchDelaySettings.DEFAULT),args[2]);} diff --git a/src/main/java/games/dmg/fantasystick/session/EquipmentSessions.java b/src/main/java/games/dmg/fantasystick/session/EquipmentSessions.java index 0ef6553..85076e8 100644 --- a/src/main/java/games/dmg/fantasystick/session/EquipmentSessions.java +++ b/src/main/java/games/dmg/fantasystick/session/EquipmentSessions.java @@ -73,6 +73,7 @@ public final class EquipmentSessions implements AutoCloseable { private final Map pending = new HashMap<>(); private final Map owned = new HashMap<>(); private final Map active = new HashMap<>(); + private final java.util.ArrayDeque activeRotation = new java.util.ArrayDeque<>(); private final Map characterRecoveries = new HashMap<>(); private final java.util.Set recoveryRecipients = new java.util.HashSet<>(); private final java.util.ArrayDeque cooldownRefreshOrder = new java.util.ArrayDeque<>(); @@ -379,6 +380,21 @@ public final class EquipmentSessions implements AutoCloseable { .filter(snapshot -> current(snapshot.session())).toList(); } + /** Bounded fair server-thread traversal; stale session identities retire without population snapshots. */ + public synchronized List rotateActiveCharacters(int limit) { + if (limit < 1 || limit > 256) throw new IllegalArgumentException("Active traversal limit must be 1–256"); + if (closed) return List.of(); + var result = new java.util.ArrayList(); + int count = Math.min(limit, activeRotation.size()); + for (int index = 0; index < count; index++) { + var session = activeRotation.removeFirst(); var attempt = active.get(session.player()); + if (attempt == null || !attempt.session.equals(session.session()) || !attempt.character.equals(session.character())) continue; + activeRotation.addLast(session); + if (attempt.record != null) result.add(new ActiveCharacter(session, attempt.record)); + } + return List.copyOf(result); + } + /** Current active holder for a transferable identity, used only to synchronize acknowledged character writes. */ public synchronized Optional activeCharacter(UUID character) { Objects.requireNonNull(character); @@ -765,6 +781,7 @@ public final class EquipmentSessions implements AutoCloseable { attempt.record = record; attempt.nextCooldownVisualChange = nextCooldownVisualChange(record); active.put(attempt.owner, attempt); + activeRotation.addLast(new Active(attempt.owner, attempt.session, attempt.character)); queueCooldownRefresh(attempt); attempt.presentationInstalled = true; try { presentation.activate(attempt.player, attempt.session, record); } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 52bc287..ad1bb54 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -6,6 +6,12 @@ admin-revive-selection-timeout-seconds: 60 # Real-time delay before this player may enter a different character after exit. character-swap-cooldown-seconds: 900 roleplay-disabled: false +# Automatic native Speed I for living active characters grounded/on foot on Dirt Paths. +# Admin: /fstickadmin config [value]; no points or passive slots. +path-speed-enabled: true +# Finite native ticks, inclusive 1–200 (default 20); subsequent grants/refreshes use acknowledged updates. +# Refused/interrupted cleanup can leave this finite lease until native expiry; offline time is not tick expiry. +path-speed-lease-ticks: 20 party-max-size: 6 party-registration-price: 10 party-leader-change-price: 10 diff --git a/src/nativeTest/java/games/dmg/fantasystick/AdminActionConfigurationCatalogNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/AdminActionConfigurationCatalogNativeTest.java index bf9fe8e..86de082 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/AdminActionConfigurationCatalogNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/AdminActionConfigurationCatalogNativeTest.java @@ -20,7 +20,7 @@ class AdminActionConfigurationCatalogNativeTest { private static final List> FAMILIES = List.of( List.of("adventure-mode"), List.of(AdminReviveSettings.KEY), List.of(SwitchDelaySettings.KEY), - List.of(RoleplaySettings.KEY), List.of(PartySettings.KEY), PartyEconomySettings.keys(), + List.of(RoleplaySettings.KEY), PathSpeedSettings.keys(), List.of(PartySettings.KEY), PartyEconomySettings.keys(), GearSettings.keys(), RareSettings.keys(), DownedSettings.keys(), CoinSettings.keys(), List.of(HardMobXpSettings.KEY), AdventureQuestSettings.keys(), List.of(SheetResourcePackSettings.URL_KEY, SheetResourcePackSettings.SHA1_KEY), @@ -35,7 +35,7 @@ class AdminActionConfigurationCatalogNativeTest { private void seed() throws Exception { Files.writeString(data.resolve("config.yml"), - "retained: untouched\nfrost-shard-range-blocks: 48\nadventure-mode: true\nroleplay-disabled: false\n"); + "retained: untouched\nfrost-shard-range-blocks: 48\nadventure-mode: true\nroleplay-disabled: false\npath-speed-enabled: true\npath-speed-lease-ticks: 20\n"); } private static JsonObject values(JsonObject row) { return row.getAsJsonObject("data"); } @@ -150,6 +150,7 @@ class AdminActionConfigurationCatalogNativeTest { private static final List WRITES = List.of( new String[]{"adventure-mode", "false"}, new String[]{AdminReviveSettings.KEY, "61"}, new String[]{SwitchDelaySettings.KEY, "901"}, new String[]{RoleplaySettings.KEY, "true"}, + new String[]{PathSpeedSettings.ENABLED, "false"}, new String[]{PartySettings.KEY, "7"}, new String[]{"party-registration-price", "11"}, new String[]{"gear-price-leather", "3"}, new String[]{"rare-price-reskill", "2001"}, new String[]{"downed-timeout-seconds", "301"}, new String[]{"starting-coins-min", "2"}, diff --git a/src/nativeTest/java/games/dmg/fantasystick/CharacterBookNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/CharacterBookNativeTest.java index 9cba342..842185f 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/CharacterBookNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/CharacterBookNativeTest.java @@ -418,6 +418,8 @@ class CharacterBookNativeTest { var categorized = new java.util.LinkedHashMap>(); var indexPageByAbility = new java.util.HashMap(); int partyPage = firstPageStarting(pages, "Party"); + assertTrue(firstPageStarting(pages, "Dirt Path travel") > partyPage, + "Road guidance must preserve the existing linked detail/inventory/party section boundaries"); for (int page = index; page < pages.size() && pages.get(page).getString().startsWith("Ability index"); page++) { var lines = pages.get(page).getString().split("\\n"); assertTrue(lines.length >= 2); String category = lines[1]; diff --git a/src/nativeTest/java/games/dmg/fantasystick/FantasyStickLifecycleNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/FantasyStickLifecycleNativeTest.java index 7745492..fbf1364 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/FantasyStickLifecycleNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/FantasyStickLifecycleNativeTest.java @@ -60,8 +60,10 @@ class FantasyStickLifecycleNativeTest { assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0); assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe); assertNotNull(fixture.packetListener); - assertEquals(19, fixture.timers.size(), - "Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery, passive effects, armor/rare/adventure/fishing/dungeon/downed refresh and session particles"); + assertTrue(fixture.order.indexOf("PathSpeed") >= 0); + assertTrue(fixture.order.indexOf("PathSpeed") < recipe); + assertEquals(20, fixture.timers.size(), + "Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery, passive effects, road travel, armor/rare/adventure/fishing/dungeon/downed refresh and session particles"); assertTrue(fixture.order.indexOf("PartyCombat") >= 0); assertTrue(fixture.order.indexOf("PartyCombat") < recipe); assertTrue(fixture.order.indexOf("CombatExperience") >= 0); @@ -1618,7 +1620,7 @@ class FantasyStickLifecycleNativeTest { 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", + for (String name : List.of("characters", "receipts", "adventureSettings", "pathSpeedSettings", "revivals", "trainingDummies", "hardMobXpSettings", "adventureQuestSettings", "adventureQuests")) { var store = field(instance, name); if (store != null) { @@ -1792,6 +1794,9 @@ class FantasyStickLifecycleNativeTest { case "getHandle" -> nativePlayer; case "getTicksLived" -> nativePlayer.tickCount; case "getEntityId" -> 42; + // Inventory-only actors have no physics support; road cases explicitly supply these ports. + case "isOnGround", "isFlying", "isSwimming", "isGliding" -> false; + case "getVelocity" -> new org.bukkit.util.Vector(); case "getName" -> accountName; case "hasPermission" -> allowed; case "getWorld" -> core.location.getWorld(); diff --git a/src/nativeTest/java/games/dmg/fantasystick/PassivePotionCleanupNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/PassivePotionCleanupNativeTest.java index 6105170..cb24e62 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/PassivePotionCleanupNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/PassivePotionCleanupNativeTest.java @@ -413,13 +413,13 @@ class PassivePotionCleanupNativeTest { && listener.getListener().getClass().getName().equals("games.dmg.fantasystick.passive.PersistentPotionEffect")).count(); } - private static void acceptDeath(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor actor) throws Exception { + static void acceptDeath(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor actor) throws Exception { clickMenu(f, actor, 11); ReskillEventsNativeTest.await(f, () -> sessions(f).active(actor.core.id).isEmpty()); DownedSessionNativeTest.awaitTail(f, actor); assertTrue(sessions(f).downed(actor.core.id).isEmpty()); } - private static void clickMenu(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor actor, int slot) { + static void clickMenu(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor actor, int slot) { var menu = actor.inventories.getLast(); var view = new org.bukkit.craftbukkit.inventory.CraftAbstractInventoryView() { @Override public org.bukkit.inventory.Inventory getTopInventory() { return menu; } @@ -462,7 +462,7 @@ class PassivePotionCleanupNativeTest { var effect = port.current(type); assertNotNull(effect); assertTrue(effect.isInfinite()); assertEquals(0, effect.getAmplifier()); } } - private static void down(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor actor) throws Exception { + static void down(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor actor) throws Exception { actor.core.health = 0; doReturn(null).when(actor.player).getLastDamageCause(); doReturn(null).when(actor.player).getFishHook(); var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource( @@ -522,7 +522,7 @@ class PassivePotionCleanupNativeTest { } } - private static final class NativePort implements org.bukkit.event.Listener { + static final class NativePort implements org.bukkit.event.Listener { boolean cancelRemoval, lateCancelRemoval, failAfterRemoval, deferRemoval, failRemovalAfterDecision; final List deferred = new ArrayList<>(); int failedObservations; @@ -555,10 +555,12 @@ class PassivePotionCleanupNativeTest { var field = net.minecraft.world.entity.LivingEntity.class.getDeclaredField("tickingEffects"); field.setAccessible(true); field.setBoolean(actor.nativePlayer, value); deferRemoval = value; } - private void remove(PotionEffectType type) { + void milk() { for (var type : List.copyOf(effects.keySet())) remove(type, EntityPotionEffectEvent.Cause.MILK); } + private void remove(PotionEffectType type) { remove(type, EntityPotionEffectEvent.Cause.PLUGIN); } + private void remove(PotionEffectType type, EntityPotionEffectEvent.Cause cause) { var old = current(type); if (old == null) return; - var event = new EntityPotionEffectEvent(actor.player, old, null, null, EntityPotionEffectEvent.Cause.PLUGIN, + var event = new EntityPotionEffectEvent(actor.player, old, null, null, cause, EntityPotionEffectEvent.Action.REMOVED, false); fixture.dispatcher.accept(event); decisions.add(event); if (failRemovalAfterDecision) throw new IllegalStateException("Native removal failed after decision, before mutation"); diff --git a/src/nativeTest/java/games/dmg/fantasystick/PathSpeedConfigurationNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/PathSpeedConfigurationNativeTest.java new file mode 100644 index 0000000..78d2d59 --- /dev/null +++ b/src/nativeTest/java/games/dmg/fantasystick/PathSpeedConfigurationNativeTest.java @@ -0,0 +1,145 @@ +package games.dmg.fantasystick; + +import static org.junit.jupiter.api.Assertions.*; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class PathSpeedConfigurationNativeTest { + @TempDir Path data; + @BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); } + @Test void registeredAdministratorReadUpdateAndCompletionUseDurableRoadPolicy() throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var sender = new EquipmentAdminCommandNativeTest.Sender(); + f.command.execute(sender.port, "fstickadmin", new String[]{"config", "path-speed-lease-ticks"}); + assertTrue(sender.messages.getLast().contains("path-speed-lease-ticks=20"), "Registered config read reports the effective road lease"); + assertEquals(List.of("path-speed-enabled", "path-speed-lease-ticks"), f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "path-speed-"})); + assertEquals(List.of("false", "true"), f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "path-speed-enabled", ""})); + assertEquals(List.of("20"), f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "path-speed-lease-ticks", ""})); + var actor = new FantasyStickLifecycleNativeTest.Actor(data); + var effects = new PassivePotionCleanupNativeTest.NativePort(f, actor); PathSpeedNativeTest.support(actor); + f.players.put(actor.core.id, actor.player); f.activate(actor); PathSpeedNativeTest.pulse(f); + assertEquals(20, effects.current(org.bukkit.potion.PotionEffectType.SPEED).getDuration()); + f.command.execute(sender.port, "fstickadmin", new String[]{"config", "path-speed-lease-ticks", "1"}); + assertEquals(20, effects.current(org.bukkit.potion.PotionEffectType.SPEED).getDuration(), "Queued save alone cannot change native policy"); + f.tick(); + assertEquals(20, effects.current(org.bukkit.potion.PotionEffectType.SPEED).getDuration(), "A shorter refused refresh preserves the captured existing finite budget"); + actor.core.location.setY(65); PathSpeedNativeTest.pulse(f); + actor.core.location.setY(64.9375); PathSpeedNativeTest.pulse(f); + assertEquals(1, effects.current(org.bukkit.potion.PotionEffectType.SPEED).getDuration()); + assertTrue(sender.messages.getLast().contains("Saved path-speed-lease-ticks=1")); + assertTrue(java.nio.file.Files.readString(data.resolve("config.yml")).contains("path-speed-lease-ticks: 1")); + assertEquals(List.of("1", "20"), f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "path-speed-lease-ticks", ""})); + f.command.execute(sender.port, "fstickadmin", new String[]{"config", "path-speed-enabled", "false"}); f.tick(); + assertTrue(sender.messages.getLast().contains("Saved path-speed-enabled=false")); + assertNull(effects.current(org.bukkit.potion.PotionEffectType.SPEED)); + f.plugin.setEnabled(false); f.plugin.setEnabled(true); + f.command.execute(sender.port, "fstickadmin", new String[]{"config", "path-speed-lease-ticks"}); + assertTrue(sender.messages.getLast().contains("=1")); + f.command.execute(sender.port, "fstickadmin", new String[]{"config", "path-speed-enabled"}); + assertTrue(sender.messages.getLast().contains("=false")); + } + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void lostCompletionPermissionCannotPublishItsPolicyOrLeakThroughASubsequentDifferentKey(boolean subsequentLease) throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var sender = new EquipmentAdminCommandNativeTest.Sender(); + var a = new FantasyStickLifecycleNativeTest.Actor(data); + var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); PathSpeedNativeTest.support(a); + f.players.put(a.core.id, a.player); f.activate(a); PathSpeedNativeTest.pulse(f); + var original = effects.effects.get(org.bukkit.potion.PotionEffectType.SPEED); + var settings = (games.dmg.fantasystick.persistence.PathSpeedSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "pathSpeedSettings"); + f.command.execute(sender.port, "fstickadmin", new String[]{"config", "path-speed-enabled", "false"}); + sender.allowed = false; f.tick(); + if (subsequentLease) { + sender.allowed = true; + f.command.execute(sender.port, "fstickadmin", new String[]{"config", "path-speed-lease-ticks", "7"}); f.tick(); + assertEquals(7, settings.settings().leaseTicks()); + } + assertTrue(settings.settings().enabled(), "Native completion must recheck authority, and another key cannot publish the interrupted toggle"); + assertSame(original, effects.effects.get(org.bukkit.potion.PotionEffectType.SPEED)); + } + } + + @Test void acceptedRoadWriteDrainsBeforeNewLifetimeStartsAndOldCallbackCannotPublish() throws Exception { + var release = new java.util.concurrent.CountDownLatch(1); + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + try { + f.plugin.setEnabled(true); + var old = (games.dmg.fantasystick.persistence.PathSpeedSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "pathSpeedSettings"); + var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(old, "worker"); + var entered = new java.util.concurrent.CountDownLatch(1); + worker.execute(() -> { entered.countDown(); try { release.await(); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } }); + assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + var sender = new EquipmentAdminCommandNativeTest.Sender(); + f.command.execute(sender.port, "fstickadmin", new String[]{"config", "path-speed-enabled", "false"}); + f.plugin.setEnabled(false); f.plugin.setEnabled(true); + assertNull(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions")); + release.countDown(); + ReskillEventsNativeTest.await(f, () -> FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions") != null); + var current = (games.dmg.fantasystick.persistence.PathSpeedSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "pathSpeedSettings"); + assertNotSame(old, current); assertFalse(current.settings().enabled()); assertTrue(old.settings().enabled()); + assertFalse(sender.messages.stream().anyMatch(message -> message.startsWith("Saved path-speed-enabled"))); + } finally { release.countDown(); } + } + } + + @Test void registeredValidationPermissionAndReadOnlyCompletionCannotMutateRoadPolicy() throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var sender = new EquipmentAdminCommandNativeTest.Sender(); + var setting = (games.dmg.fantasystick.persistence.PathSpeedSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "pathSpeedSettings"); + for (String value : List.of("0", "201", "-1", "1.5", "", "999999999999999999999")) { + f.command.execute(sender.port, "fstickadmin", new String[]{"config", "path-speed-lease-ticks", value}); + assertTrue(sender.messages.getLast().contains("1–200")); assertEquals(20, setting.settings().leaseTicks()); + } + for (String value : List.of("TRUE", "yes", "1", "")) { + f.command.execute(sender.port, "fstickadmin", new String[]{"config", "path-speed-enabled", value}); + assertTrue(sender.messages.getLast().contains("true or false")); assertTrue(setting.settings().enabled()); + } + var file = data.resolve("config.yml"); boolean existed = java.nio.file.Files.exists(file); + var before = existed ? java.nio.file.Files.readAllBytes(file) : new byte[0]; + assertTrue(f.command.tabComplete(sender.port, "fstickadmin", new String[]{""}).contains("config")); + assertEquals(List.of("config"), f.command.tabComplete(sender.port, "fstickadmin", new String[]{"con"})); + assertEquals(List.of("true"), f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "path-speed-enabled", "t"})); + assertTrue(f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "path-speed-enabled", "true", ""}).isEmpty()); + assertTrue(f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "not-a-key", ""}).isEmpty()); + assertEquals(existed, java.nio.file.Files.exists(file)); + if (existed) assertArrayEquals(before, java.nio.file.Files.readAllBytes(file)); + sender.allowed = false; + assertTrue(f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "path-speed-"}).isEmpty()); + f.command.execute(sender.port, "fstickadmin", new String[]{"config", "path-speed-enabled", "false"}); + assertTrue(sender.messages.getLast().contains("Permission denied")); assertTrue(setting.settings().enabled()); + sender.allowed = true; + f.command.execute(sender.port, "fstickadmin", new String[]{"config", "path-speed-enabled", "false", "extra"}); + assertTrue(sender.messages.getLast().contains("Usage")); assertTrue(setting.settings().enabled()); + } + } + @Test void failedRegisteredSaveLeavesEffectivePolicyAndActiveRoadLayerUnchanged() throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var sender = new EquipmentAdminCommandNativeTest.Sender(); + var actor = new FantasyStickLifecycleNativeTest.Actor(data); + var effects = new PassivePotionCleanupNativeTest.NativePort(f, actor); PathSpeedNativeTest.support(actor); + f.players.put(actor.core.id, actor.player); f.activate(actor); PathSpeedNativeTest.pulse(f); + var original = effects.effects.get(org.bukkit.potion.PotionEffectType.SPEED); + 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", "path-speed-enabled", "false"}); f.tick(); + } + assertTrue(sender.messages.getLast().contains("save failed")); + assertSame(original, effects.effects.get(org.bukkit.potion.PotionEffectType.SPEED)); + var settings = (games.dmg.fantasystick.persistence.PathSpeedSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "pathSpeedSettings"); + assertTrue(settings.settings().enabled()); + } + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"path-speed-enabled: 'true'", "path-speed-enabled: 1", "path-speed-lease-ticks: 0", "path-speed-lease-ticks: 201", "path-speed-lease-ticks: 1.5", "path-speed-lease-ticks: '20'"}) + void invalidTypedStartupIsRefusedBeforeFeatureInstallation(String contents) throws Exception { + java.nio.file.Files.writeString(data.resolve("config.yml"), contents + "\n"); + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true)); assertTrue(f.order.isEmpty()); assertTrue(f.pulses.isEmpty()); + } + } +} diff --git a/src/nativeTest/java/games/dmg/fantasystick/PathSpeedInterruptionNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/PathSpeedInterruptionNativeTest.java new file mode 100644 index 0000000..62e71fb --- /dev/null +++ b/src/nativeTest/java/games/dmg/fantasystick/PathSpeedInterruptionNativeTest.java @@ -0,0 +1,179 @@ +package games.dmg.fantasystick; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static games.dmg.fantasystick.PathSpeedNativeTest.*; +import java.nio.file.Path; +import org.bukkit.event.entity.EntityPotionEffectEvent; +import org.bukkit.potion.PotionEffectType; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class PathSpeedInterruptionNativeTest { + @TempDir Path data; + @BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); } + @ParameterizedTest @ValueSource(booleans = {false, true}) + void realMilkDecisionCreatesANewApplicationOnlyAfterAcceptedRemoval(boolean veto) throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); + var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a); + f.players.put(a.core.id, a.player); f.activate(a); pulse(f); + var original = effects.effects.get(PotionEffectType.SPEED); + ReskillEventsNativeTest.await(f, () -> roadEvents(data).size() == 1); + effects.lateCancelRemoval = veto; effects.milk(); pulse(f); + assertTrue(effects.decisions.stream().anyMatch(e -> e.getCause() == EntityPotionEffectEvent.Cause.MILK && e.isCancelled() == veto)); + if (veto) assertSame(original, effects.effects.get(PotionEffectType.SPEED)); + else assertNotSame(original, effects.effects.get(PotionEffectType.SPEED)); + effects.lateCancelRemoval = false; f.plugin.setEnabled(false); + ReskillEventsNativeTest.await(f, () -> roadEvents(data).size() == (veto ? 2 : 4)); + var rows = roadEvents(data); + assertEquals(veto ? 1 : 2, rows.stream().filter(r -> r.get("type").getAsString().endsWith("effect.applied")).count()); + assertEquals(veto ? 1 : 2, rows.stream().map(r -> r.getAsJsonObject("data").get("application_id")).distinct().count()); + assertNull(effects.current(PotionEffectType.SPEED)); + } + } + @Test void transferredSheetAndRetiredTimerCannotBoostTheFormerHolder() throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); + var first = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a); + f.players.put(a.core.id, a.player); f.activate(a); pulse(f); + var sheet = a.core.inventory.getItemInOffHand().clone(); + a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a); PartyBookNativeTest.drain(f); pulse(f); + assertNull(first.current(PotionEffectType.SPEED)); + var b = new FantasyStickLifecycleNativeTest.Actor(data); var second = new PassivePotionCleanupNativeTest.NativePort(f, b); support(b); + b.core.inventory.setItemInOffHand(sheet); f.players.put(b.core.id, b.player); f.activate(b); pulse(f); + var sessions = (games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"); + assertEquals(a.core.record.id(), sessions.active(b.core.id).orElseThrow().character()); + assertNull(first.current(PotionEffectType.SPEED)); assertNotNull(second.current(PotionEffectType.SPEED)); + var retired = f.pulses.stream().filter(p -> p.getClass().getName().startsWith("games.dmg.fantasystick.passive.PathSpeed")).findFirst().orElseThrow(); + f.plugin.setEnabled(false); f.plugin.setEnabled(true); retired.run(); + assertNull(first.current(PotionEffectType.SPEED)); assertNull(second.current(PotionEffectType.SPEED)); + } + } + @Test void writerFailureRetainsReplayableFactsButNeverReplaysSpeed() throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); + var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a); + f.players.put(a.core.id, a.player); f.activate(a); + ((games.dmg.fantasystick.events.LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close(); + pulse(f); assertEquals(20, effects.current(PotionEffectType.SPEED).getDuration()); + a.core.location.setY(65); pulse(f); assertNull(effects.current(PotionEffectType.SPEED)); + f.plugin.setEnabled(false); + ((java.util.concurrent.CompletionStage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "pathSpeedAuditDrain")).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + f.plugin.setEnabled(true); + ReskillEventsNativeTest.await(f, () -> roadEvents(data).size() == 2); + assertNull(effects.current(PotionEffectType.SPEED)); + var start = roadEvents(data).getFirst().getAsJsonObject("data"); var end = roadEvents(data).getLast().getAsJsonObject("data"); + assertEquals(start.get("application_id"), end.get("application_id")); + assertEquals(start.get("owner_session_id"), end.get("owner_session_id")); + assertEquals(a.core.record.id().toString(), start.get("owner_character_id").getAsString()); + } + } + @Test void failedAuditContextCaptureIsNotRetriedAsANewBusinessApplicationOnRefresh() throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); + var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a); + f.players.put(a.core.id, a.player); f.activate(a); + doThrow(new IllegalStateException("Controlled audit-context capture refusal")).when(a.player).getName(); + pulse(f); assertEquals(20, effects.current(PotionEffectType.SPEED).getDuration()); + doReturn("Account").when(a.player).getName(); + for (int tick = 0; tick < 3; tick++) { effects.effects.get(PotionEffectType.SPEED).tickClient(); a.nativePlayer.tickCount++; pulse(f); } + f.plugin.setEnabled(false); + ((java.util.concurrent.CompletionStage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "pathSpeedAuditDrain")).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + assertTrue(roadEvents(data).isEmpty(), "Lost native observations cannot be synthesized from later lease refreshes"); + } + } + @ParameterizedTest @ValueSource(booleans = {false, true}) + void nativeApplicationCallbacksThatExitOrCloseCannotLeaveANewRoadGrantBehind(boolean close) throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); + var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a); + f.players.put(a.core.id, a.player); f.activate(a); + class Invalidate implements org.bukkit.event.Listener { + @org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.MONITOR) + public void invalidate(EntityPotionEffectEvent event) { + if (event.getEntity() != a.player || event.getAction() != EntityPotionEffectEvent.Action.ADDED) return; + if (close) f.plugin.setEnabled(false); + else { a.core.inventory.setItemInOffHand(null); f.changed(a); } + } + } + f.dispatcher.register(new Invalidate(), f.plugin); pulse(f); + assertNull(effects.current(PotionEffectType.SPEED), "Post-mutation context invalidation requires proved-layer cleanup, not an ordinary-holder grant"); + } + } + @Test void acceptedSameSignatureForeignMutationNestedBeforeTheObserverIsNotMistakenForACancelledRoadRefresh() throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); + var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a); + f.players.put(a.core.id, a.player); f.activate(a); pulse(f); + var foreign = effects.effects.get(PotionEffectType.SPEED); + class Replace implements org.bukkit.event.Listener { + boolean fired; boolean nested; + @org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST) + public void replace(EntityPotionEffectEvent event) { + if (event.getEntity() != a.player || event.getAction() != EntityPotionEffectEvent.Action.CHANGED) return; + if (nested) { event.setOverride(true); return; } + if (fired) return; fired = true; event.setCancelled(true); nested = true; + try { assertTrue(effects.add(new org.bukkit.potion.PotionEffect(PotionEffectType.SPEED, 20, 0, false, false, true))); } + finally { nested = false; } + } + } + f.dispatcher.register(new Replace(), f.plugin); pulse(f); + a.core.location.setY(65); pulse(f); + assertSame(foreign, effects.effects.get(PotionEffectType.SPEED), "Final cancellation and identical native fields do not hide a separately accepted nested foreign mutation"); + } + } + + @ParameterizedTest @ValueSource(strings = {"cancelled", "thrown", "native_tick_phase"}) + void refusedApplicationNeverAcquiresOwnershipOrPublishesSuccess(String refusal) throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); + var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a); + f.players.put(a.core.id, a.player); f.activate(a); + if (refusal.equals("native_tick_phase")) effects.ticking(true); + else if (refusal.equals("thrown")) doThrow(new IllegalStateException("Controlled native admission failure")).when(a.player) + .addPotionEffect(org.mockito.ArgumentMatchers.any(org.bukkit.potion.PotionEffect.class), org.mockito.ArgumentMatchers.anyBoolean()); + else { + class Veto implements org.bukkit.event.Listener { + @org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST) + public void veto(EntityPotionEffectEvent event) { if (event.getEntity() == a.player) event.setCancelled(true); } + } + f.dispatcher.register(new Veto(), f.plugin); + } + pulse(f); pulse(f); + assertNull(effects.current(PotionEffectType.SPEED)); assertTrue(effects.deferred.isEmpty()); + var road = (games.dmg.fantasystick.passive.PathSpeed) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "pathSpeed"); + assertFalse(road.hasRoadLease(a.player)); + effects.ticking(false); f.plugin.setEnabled(false); + ((java.util.concurrent.CompletionStage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "pathSpeedAuditDrain")).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + assertTrue(roadEvents(data).isEmpty()); + } + } + + @Test void partialRoadRegistrationClosesAllInstalledTimersAndNeverExposesTheRecipe() throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.failRegistrationAt = "PathSpeed"; assertThrows(IllegalStateException.class, () -> f.plugin.setEnabled(true)); + assertFalse(f.recipes.containsKey(games.dmg.fantasystick.items.CharacterSheets.RECIPE)); + f.timers.forEach(timer -> verify(timer).cancel()); + } + } + @Test void boundedSessionTraversalRotatesFairlyAndDiscardsRetiredSessionIdentities() throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var actors = new java.util.ArrayList(); + for (int i = 0; i < 5; i++) { + var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player); f.activate(a); actors.add(a); + } + var sessions = (games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"); + var seen = new java.util.HashSet(); + for (int turn = 0; turn < 3; turn++) { var batch = sessions.rotateActiveCharacters(2); assertTrue(batch.size() <= 2); batch.forEach(v -> seen.add(v.session().player())); } + assertEquals(5, seen.size()); var retired = sessions.active(actors.getFirst().core.id).orElseThrow(); + actors.getFirst().core.inventory.setItemInOffHand(null); f.changed(actors.getFirst()); + for (int turn = 0; turn < 3; turn++) assertTrue(sessions.rotateActiveCharacters(2).stream().noneMatch(v -> v.session().equals(retired))); + assertThrows(IllegalArgumentException.class, () -> sessions.rotateActiveCharacters(0)); + assertThrows(IllegalArgumentException.class, () -> sessions.rotateActiveCharacters(257)); + } + } +} diff --git a/src/nativeTest/java/games/dmg/fantasystick/PathSpeedNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/PathSpeedNativeTest.java new file mode 100644 index 0000000..7d034b9 --- /dev/null +++ b/src/nativeTest/java/games/dmg/fantasystick/PathSpeedNativeTest.java @@ -0,0 +1,370 @@ +package games.dmg.fantasystick; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; + +import java.nio.file.Path; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.Blocks; +import org.bukkit.potion.PotionEffectType; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class PathSpeedNativeTest { + @TempDir Path data; + @BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); } + + static void pulse(FantasyStickLifecycleNativeTest.Fixture f) { + for (var task : f.pulses) if (task.getClass().getName().startsWith("games.dmg.fantasystick.passive.PathSpeed")) task.run(); + } + static void support(FantasyStickLifecycleNativeTest.Actor actor) { + actor.core.location.setX(0.5); actor.core.location.setY(64.9375); actor.core.location.setZ(0.5); + doReturn(true).when(actor.player).isOnGround(); + doReturn(false).when(actor.player).isSwimming(); doReturn(false).when(actor.player).isFlying(); + doReturn(false).when(actor.player).isGliding(); + var level = actor.nativePlayer.level(); + doReturn(Blocks.DIRT_PATH.defaultBlockState()).when(level).getBlockStateIfLoaded(any(BlockPos.class)); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"jump", "jump_takeoff", "native_dead", "swim", "fly", "glide", "mount", "full_height", "airborne_path", "replaced", "unloaded", "inactive"}) + void excludedStatesAndStationaryTerrainChangesRemoveOnlyTheOwnedRoadLayer(String state) throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); + var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a); + f.players.put(a.core.id, a.player); f.activate(a); pulse(f); + assertNotNull(effects.current(PotionEffectType.SPEED)); + switch (state) { + case "jump" -> doReturn(false).when(a.player).isOnGround(); + case "jump_takeoff" -> doReturn(new org.bukkit.util.Vector(0, 0.42, 0)).when(a.player).getVelocity(); + case "native_dead" -> a.core.health = 0; + case "swim" -> doReturn(true).when(a.player).isSwimming(); + case "fly" -> doReturn(true).when(a.player).isFlying(); + case "glide" -> doReturn(true).when(a.player).isGliding(); + case "mount" -> doReturn(true).when(a.player).isInsideVehicle(); + case "full_height" -> a.core.location.setY(65); + case "airborne_path" -> a.core.location.setY(66); + case "replaced", "unloaded" -> { + var level = a.nativePlayer.level(); + doReturn(state.equals("unloaded") ? null : Blocks.DIRT.defaultBlockState()).when(level).getBlockStateIfLoaded(any(BlockPos.class)); + } + case "inactive" -> { a.core.inventory.setItemInOffHand(null); f.changed(a); } + default -> throw new AssertionError(state); + } + pulse(f); assertNull(effects.current(PotionEffectType.SPEED)); + } + } + + @Test void cancelledCleanupRetainsIdentityAndRetriesWithoutRenewingIneligibleSpeed() throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); + var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a); + f.players.put(a.core.id, a.player); f.activate(a); pulse(f); + var original = effects.effects.get(PotionEffectType.SPEED); + effects.lateCancelRemoval = true; a.core.location.setY(65); pulse(f); pulse(f); + assertSame(original, effects.effects.get(PotionEffectType.SPEED)); assertEquals(20, original.getDuration()); + effects.lateCancelRemoval = false; pulse(f); assertNull(effects.current(PotionEffectType.SPEED)); + } + } + + @Test void foreignRemovalCancelledAfterRoadObserverRetainsTheRoadLeaseAndRenewalAuthority() throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); + var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a); + f.players.put(a.core.id, a.player); f.activate(a); pulse(f); + var original = effects.effects.get(PotionEffectType.SPEED); effects.lateCancelRemoval = true; + a.player.removePotionEffect(PotionEffectType.SPEED); assertSame(original, effects.effects.get(PotionEffectType.SPEED)); + original.tickClient(); a.nativePlayer.tickCount++; pulse(f); + assertEquals(20, effects.current(PotionEffectType.SPEED).getDuration(), "A finally cancelled foreign removal cannot discard current road ownership"); + effects.lateCancelRemoval = false; a.core.location.setY(65); pulse(f); assertNull(effects.current(PotionEffectType.SPEED)); + } + } + + @Test void learnedSpeedTakingOverRoadSpeedRemainsAfterLeavingRoad() throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); + // Completed passive acquisition prerequisite, using the existing strict saved-record fixture. + SpeedINativeTest.withDex(data, a, java.util.List.of("", "", "")); + var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a); + f.players.put(a.core.id, a.player); f.activate(a); pulse(f); + assertNotNull(effects.current(PotionEffectType.SPEED)); + // Learn through the supported native book route; roads themselves spend nothing. + PartyBookNativeTest.open(f, a); + PartyBookNativeTest.click(f, a, SpeedINativeTest.action(a, "[Learn]", "speed_i")); + PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); + ((games.dmg.fantasystick.passive.SpeedI) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "speedI")).sweep(); + a.core.location.setY(65); pulse(f); + var speed = effects.current(PotionEffectType.SPEED); + assertNotNull(speed, "Leaving the road cannot remove the other applicable learned Speed I layer"); + assertTrue(speed.isInfinite()); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void realRallyingCryAndRoadSourcesPreserveTheSurvivorInBothActivationOrders(boolean rallyFirst) throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); + RallyingCryNativeTest.prepareCaster(data, a, true); + var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a); + org.mockito.Mockito.doReturn(java.util.List.of()).when(a.player).getNearbyEntities( + org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.anyDouble()); + f.players.put(a.core.id, a.player); f.activate(a); + if (!rallyFirst) pulse(f); + RallyingCryNativeTest.use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); + if (rallyFirst) pulse(f); + assertNotNull(effects.current(PotionEffectType.SPEED)); + assertTrue(effects.current(PotionEffectType.SPEED).isInfinite(), "Rally keeps its native persistent policy rather than borrowing an expiring road layer"); + a.core.location.setY(65); pulse(f); assertNotNull(effects.current(PotionEffectType.SPEED)); + assertTrue(effects.current(PotionEffectType.SPEED).isInfinite()); + a.core.location.setY(64.9375); + f.delayed.getLast().run(); pulse(f); + assertNotNull(effects.current(PotionEffectType.SPEED)); + assertFalse(effects.current(PotionEffectType.SPEED).isInfinite()); + assertEquals(20, effects.current(PotionEffectType.SPEED).getDuration()); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(ints = {1, 20, 200}) + void genuineNativeTickExpiryKeepsOneLogicalApplicationAcrossContinuousRoadLeases(int lease) throws Exception { + java.nio.file.Files.writeString(data.resolve("config.yml"), "path-speed-lease-ticks: " + lease + "\n"); + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); + var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a); + f.players.put(a.core.id, a.player); f.activate(a); pulse(f); + for (int tick = 0; tick < 25; tick++) { + var effect = effects.effects.get(PotionEffectType.SPEED); assertNotNull(effect); a.nativePlayer.tickCount++; + if (!effect.tickServer(a.nativePlayer.level(), a.nativePlayer, () -> {})) { + var event = new org.bukkit.event.entity.EntityPotionEffectEvent(a.player, effects.current(PotionEffectType.SPEED), null, null, + org.bukkit.event.entity.EntityPotionEffectEvent.Cause.EXPIRATION, org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED, false); + f.dispatcher.accept(event); assertFalse(event.isCancelled()); effects.effects.remove(PotionEffectType.SPEED); + } + pulse(f); assertEquals(lease, effects.current(PotionEffectType.SPEED).getDuration()); + } + f.plugin.setEnabled(false); + var rows = roadEvents(data); + assertEquals(2, rows.size(), "Continuous native one-tick renewals are one logical start/end, not per-tick business spam"); + assertEquals(rows.getFirst().getAsJsonObject("data").get("application_id"), rows.getLast().getAsJsonObject("data").get("application_id")); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(ints = {0, 1}) + void existingEqualOrStrongerForeignSpeedAndOrdinaryInactiveHoldersAreUntouched(int tier) throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); + var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a); + f.players.put(a.core.id, a.player); pulse(f); assertNull(effects.current(PotionEffectType.SPEED)); + assertTrue(effects.add(new org.bukkit.potion.PotionEffect(PotionEffectType.SPEED, 200, tier))); + var foreign = effects.effects.get(PotionEffectType.SPEED); f.activate(a); pulse(f); + assertSame(foreign, effects.effects.get(PotionEffectType.SPEED)); + a.core.location.setY(65); pulse(f); assertSame(foreign, effects.effects.get(PotionEffectType.SPEED)); + assertEquals(200, foreign.getDuration()); assertTrue(roadEvents(data).isEmpty()); + } + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"extended", "stronger_hidden", "identical_replacement"}) + void inPlaceAndIdenticalForeignLayersIncludingHiddenNativeChainsArePreserved(String change) throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); + var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a); + f.players.put(a.core.id, a.player); f.activate(a); pulse(f); + var original = effects.effects.get(PotionEffectType.SPEED); + if (change.equals("identical_replacement")) effects.effects.put(PotionEffectType.SPEED, new net.minecraft.world.effect.MobEffectInstance(original)); + else assertTrue(effects.add(new org.bukkit.potion.PotionEffect(PotionEffectType.SPEED, + change.equals("extended") ? 40 : 10, change.equals("extended") ? 0 : 1, false, false, true))); + var foreign = effects.effects.get(PotionEffectType.SPEED); var hidden = foreign.hiddenEffect; + if (change.equals("stronger_hidden")) assertNotNull(hidden); + else if (!change.equals("identical_replacement")) assertSame(original, foreign, "Native update mutates the same instance; identity alone is not ownership"); + pulse(f); a.core.location.setY(65); pulse(f); + assertSame(foreign, effects.effects.get(PotionEffectType.SPEED)); assertSame(hidden, foreign.hiddenEffect); + assertEquals(change.equals("extended") ? 40 : change.equals("stronger_hidden") ? 10 : 20, foreign.getDuration()); + } + } + @Test void refusedForeignNoOpDoesNotDiscardOwnershipAndUnsafeNativePhaseDefersCleanup() throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); + var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a); + f.players.put(a.core.id, a.player); f.activate(a); pulse(f); + assertFalse(effects.add(new org.bukkit.potion.PotionEffect(PotionEffectType.SPEED, 20, 0, false, false, true))); + var original = effects.effects.get(PotionEffectType.SPEED); original.tickClient(); a.nativePlayer.tickCount++; pulse(f); + assertEquals(20, original.getDuration()); + a.core.location.setY(65); effects.ticking(true); int before = effects.decisions.size(); pulse(f); + assertSame(original, effects.effects.get(PotionEffectType.SPEED)); assertEquals(before, effects.decisions.size()); assertTrue(effects.deferred.isEmpty()); + effects.ticking(false); effects.failRemovalAfterDecision = true; pulse(f); + assertSame(original, effects.effects.get(PotionEffectType.SPEED)); + effects.failRemovalAfterDecision = false; pulse(f); assertNull(effects.current(PotionEffectType.SPEED)); + } + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void actualDownedContainmentRetainsFiniteCleanupAndFinalDeathOrPhoenixRescueReconcilesIt(boolean rescue) throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); + var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a); + f.players.put(a.core.id, a.player); f.activate(a); pulse(f); + var sessions = (games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"); + var active = sessions.active(a.core.id).orElseThrow(); var original = effects.effects.get(PotionEffectType.SPEED); + if (rescue) { + var inventory = (games.dmg.fantasystick.shop.RareInventoryDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "rareInventory"); + inventory.add(active.character(), games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN, java.util.UUID.randomUUID()).join(); + } + PassivePotionCleanupNativeTest.down(f, a); pulse(f); pulse(f); + assertSame(original, effects.effects.get(PotionEffectType.SPEED)); assertFalse(effects.current(PotionEffectType.SPEED).isInfinite()); + assertTrue(effects.decisions.stream().anyMatch(event -> event.getAction() == org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED && event.isCancelled())); + if (rescue) { + PassivePotionCleanupNativeTest.clickMenu(f, a, 13); ReskillEventsNativeTest.await(f, () -> sessions.current(active)); pulse(f); + assertSame(original, effects.effects.get(PotionEffectType.SPEED)); + a.core.location.setY(65); pulse(f); assertNull(effects.current(PotionEffectType.SPEED)); + } else { PassivePotionCleanupNativeTest.acceptDeath(f, a); pulse(f); assertNull(effects.current(PotionEffectType.SPEED)); } + } + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void finallyCancelledMoveOrTeleportNeverGrantsAHypotheticalPathDestination(boolean teleport) throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); + var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a); a.core.location.setY(65); + f.players.put(a.core.id, a.player); f.activate(a); pulse(f); assertNull(effects.current(PotionEffectType.SPEED)); + class Veto implements org.bukkit.event.Listener { + @org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.MONITOR) + public void veto(org.bukkit.event.player.PlayerMoveEvent event) { + if (event.getPlayer() == a.player) { event.setCancelled(true); pulse(f); } + } + @org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.MONITOR) + public void teleportVeto(org.bukkit.event.player.PlayerTeleportEvent event) { veto(event); } + } + f.dispatcher.register(new Veto(), f.plugin); var from = a.player.getLocation(); var to = from.clone(); to.setY(64.9375); + org.bukkit.event.player.PlayerMoveEvent event = teleport ? new org.bukkit.event.player.PlayerTeleportEvent(a.player, from, to, + org.bukkit.event.player.PlayerTeleportEvent.TeleportCause.PLUGIN) : new org.bukkit.event.player.PlayerMoveEvent(a.player, from, to); + f.dispatcher.accept(event); assertTrue(event.isCancelled()); pulse(f); assertNull(effects.current(PotionEffectType.SPEED)); + // Accepted continuation changes actual position; event proposals alone never do. + a.core.location.setY(64.9375); pulse(f); assertNotNull(effects.current(PotionEffectType.SPEED)); + a.core.location.setY(65); pulse(f); assertNull(effects.current(PotionEffectType.SPEED)); + } + } + + @Test void cancelledNativeExpiryDuringDowningRetainsCleanupResponsibilityThroughFinalDeath() throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); + var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a); + f.players.put(a.core.id, a.player); f.activate(a); pulse(f); + var original = effects.effects.get(PotionEffectType.SPEED); PassivePotionCleanupNativeTest.down(f, a); + for (int tick = 0; tick < 20; tick++) { + a.nativePlayer.tickCount++; + if (!original.tickServer(a.nativePlayer.level(), a.nativePlayer, () -> {})) { + var event = new org.bukkit.event.entity.EntityPotionEffectEvent(a.player, effects.current(PotionEffectType.SPEED), null, null, + org.bukkit.event.entity.EntityPotionEffectEvent.Cause.EXPIRATION, org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED, false); + f.dispatcher.accept(event); assertTrue(event.isCancelled()); + } + } + assertEquals(0, original.getDuration()); pulse(f); assertSame(original, effects.effects.get(PotionEffectType.SPEED)); + PassivePotionCleanupNativeTest.acceptDeath(f, a); pulse(f); + assertNull(effects.current(PotionEffectType.SPEED), "A finally refused native expiry does not erase the road cleanup lease"); + } + } + + @Test void reentrantSessionExitDuringRoadHandoffCannotPublishAnInfinitePassiveOnTheFormerHolder() throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); + SpeedINativeTest.withDex(data, a, java.util.List.of("", "", "")); + var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a); + f.players.put(a.core.id, a.player); f.activate(a); pulse(f); + PartyBookNativeTest.open(f, a); PartyBookNativeTest.click(f, a, SpeedINativeTest.action(a, "[Learn]", "speed_i")); + PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); + class Exit implements org.bukkit.event.Listener { + @org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.MONITOR) + public void exit(org.bukkit.event.entity.EntityPotionEffectEvent event) { + if (event.getEntity() == a.player && event.getAction() == org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED) { + a.core.inventory.setItemInOffHand(null); f.changed(a); + } + } + } + f.dispatcher.register(new Exit(), f.plugin); + ((games.dmg.fantasystick.passive.SpeedI) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "speedI")).sweep(); + assertNull(effects.current(PotionEffectType.SPEED), "Handoff native callbacks must not grant persistent Speed after current-session authority disappears"); + } + } + + @Test void reentrantSessionExitDuringRoadRallyHandoffCannotPublishAnInfiniteSpeedLayer() throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); + RallyingCryNativeTest.prepareCaster(data, a, true); + var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a); + org.mockito.Mockito.doReturn(java.util.List.of()).when(a.player).getNearbyEntities( + org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.anyDouble()); + f.players.put(a.core.id, a.player); f.activate(a); pulse(f); + class Exit implements org.bukkit.event.Listener { + @org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.MONITOR) + public void exit(org.bukkit.event.entity.EntityPotionEffectEvent event) { + if (event.getEntity() == a.player && event.getAction() == org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED) { + a.core.inventory.setItemInOffHand(null); f.changed(a); + } + } + } + f.dispatcher.register(new Exit(), f.plugin); RallyingCryNativeTest.use(f, a); + PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); + assertNull(effects.current(PotionEffectType.SPEED), "Rally handoff cannot publish persistent Speed on a former source holder"); + } + } + + @Test void actualBookAndJsonlExplainAndRecordTheRoadJourneyWithoutRefreshSpam() throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); + var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a); + f.players.put(a.core.id, a.player); f.activate(a); + PartyBookNativeTest.open(f, a); + assertTrue(PartyBookNativeTest.text(a).contains("Dirt Path travel"), "The real character book teaches the automatic road benefit"); + pulse(f); + for (int i = 0; i < 50; i++) { effects.effects.get(PotionEffectType.SPEED).tickClient(); a.nativePlayer.tickCount++; pulse(f); } + var storage = (games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"); + var record = storage.find(a.core.record.id()).get().orElseThrow(); + assertEquals(a.core.record.unspentPoints(), record.unspentPoints()); assertEquals(a.core.record.passives(), record.passives()); + a.core.location.setY(65); pulse(f); assertNull(effects.current(PotionEffectType.SPEED)); + ReskillEventsNativeTest.await(f, () -> roadEvents(data).size() == 2); + var rows = roadEvents(data); assertEquals(2, rows.size()); + assertEquals(games.dmg.fantasystick.events.LocalEventLog.TYPE_PREFIX + "effect.applied", rows.getFirst().get("type").getAsString()); + assertEquals(games.dmg.fantasystick.events.LocalEventLog.TYPE_PREFIX + "effect.ended", rows.getLast().get("type").getAsString()); + var start = rows.getFirst().getAsJsonObject("data"); var end = rows.getLast().getAsJsonObject("data"); + assertEquals(start.get("application_id"), end.get("application_id")); + for (var row : rows) { + var values = row.getAsJsonObject("data"); + assertEquals(a.core.id.toString(), values.get("player_id").getAsString()); + assertEquals(record.id().toString(), values.get("character_id").getAsString()); + assertTrue(values.has("session_id")); assertTrue(values.has("location")); assertFalse(values.has("passive_id")); + assertEquals(0, values.get("amplifier").getAsInt()); + } + } + } + static java.util.List roadEvents(Path directory) { + try { return ReskillEventsNativeTest.records(directory).stream().filter(row -> { + var values = row.getAsJsonObject("data"); return values.has("feature_id") && values.get("feature_id").getAsString().equals("dirt_path_speed"); + }).toList(); } catch (Exception failure) { throw new IllegalStateException(failure); } + } + + @Test void registeredRoadSweepGrantsFiniteSpeedToAnOrdinaryActiveCharacterOnNativeLoweredPath() throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); + var actor = new FantasyStickLifecycleNativeTest.Actor(data); + var nativeEffects = new PassivePotionCleanupNativeTest.NativePort(f, actor); + actor.core.location.setX(0.5); actor.core.location.setY(64.9375); actor.core.location.setZ(0.5); + doReturn(true).when(actor.player).isOnGround(); + doReturn(false).when(actor.player).isSwimming(); + doReturn(false).when(actor.player).isFlying(); + doReturn(false).when(actor.player).isGliding(); + var level = actor.nativePlayer.level(); + doReturn(Blocks.DIRT_PATH.defaultBlockState()).when(level).getBlockStateIfLoaded(any(BlockPos.class)); + f.players.put(actor.core.id, actor.player); f.activate(actor); + // Invoke the actual installed feature timer, not an imagined domain helper. + for (var pulse : f.pulses) { + if (pulse.getClass().getName().startsWith("games.dmg.fantasystick.passive.PathSpeed")) pulse.run(); + } + var speed = nativeEffects.current(PotionEffectType.SPEED); + assertNotNull(speed, "A genuine active character supported by a loaded native Dirt Path receives road Speed I"); + assertEquals(0, speed.getAmplifier()); assertEquals(20, speed.getDuration()); assertFalse(speed.isInfinite()); + } + } +} diff --git a/src/nativeTest/java/games/dmg/fantasystick/RallyingCryNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/RallyingCryNativeTest.java index 73d7097..98884df 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/RallyingCryNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/RallyingCryNativeTest.java @@ -304,7 +304,7 @@ class RallyingCryNativeTest { .withCausingEntity(attacker).withDirectEntity(attacker).build(); return new EntityDamageByEntityEvent(attacker, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, amount); } - private static void use(FantasyStickLifecycleNativeTest.Fixture fixture, FantasyStickLifecycleNativeTest.Actor actor) { + static void use(FantasyStickLifecycleNativeTest.Fixture fixture, FantasyStickLifecycleNativeTest.Actor actor) { actor.core.inventory.getInventory().setSelectedSlot(2); fixture.dispatcher.accept(new PlayerInteractEvent(actor.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR, actor.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND)); @@ -333,7 +333,7 @@ class RallyingCryNativeTest { && payload.value().contains(":" + ability)) return custom; for (var child : component.getSiblings()) { var found = button(child, label, ability); if (found != null) return found; } return null; } - private static void prepareCaster(Path directory, FantasyStickLifecycleNativeTest.Actor actor, boolean learned) throws Exception { + static void prepareCaster(Path directory, FantasyStickLifecycleNativeTest.Actor actor, boolean learned) throws Exception { java.nio.file.Files.createDirectories(directory); var repository = new YamlCharacterRepository(directory); var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new EnumMap<>(old.stats()); stats.put(Stat.CHR, BigInteger.valueOf(16)); stats.put(Stat.CON, BigInteger.valueOf(12)); diff --git a/src/test/java/games/dmg/fantasystick/DistributionTest.java b/src/test/java/games/dmg/fantasystick/DistributionTest.java index e191a82..68448e2 100644 --- a/src/test/java/games/dmg/fantasystick/DistributionTest.java +++ b/src/test/java/games/dmg/fantasystick/DistributionTest.java @@ -71,6 +71,10 @@ class DistributionTest { assertTrue(defaults.contains("character-swap-cooldown-seconds: 900"), "The delivered configuration must expose the reviewed 15-minute default"); assertTrue(defaults.contains("adventure-mode: true")); + assertTrue(defaults.contains("path-speed-enabled: true")); + assertTrue(defaults.contains("path-speed-lease-ticks: 20")); + for (String path : java.util.List.of("passive/PathSpeed", "persistence/PathSpeedSettings", "events/PathSpeedEventBridge")) + assertNotNull(jar.getJarEntry("games/dmg/fantasystick/" + path + ".class"), "Road wiring needs its packaged production owner: " + path); for (String setting : java.util.List.of("adventure-daily-site-count: 3", "adventure-site-radius: 16", "adventure-site-reset-seconds: 30", "dungeon-room-link-distance: 10", "adventure-camp-enemies: 8", "adventure-camp-reward-xp: 100", "adventure-camp-reward-coins: 20", diff --git a/src/test/java/games/dmg/fantasystick/persistence/PathSpeedSettingsTest.java b/src/test/java/games/dmg/fantasystick/persistence/PathSpeedSettingsTest.java new file mode 100644 index 0000000..35f1f35 --- /dev/null +++ b/src/test/java/games/dmg/fantasystick/persistence/PathSpeedSettingsTest.java @@ -0,0 +1,112 @@ +package games.dmg.fantasystick.persistence; + +import static org.junit.jupiter.api.Assertions.*; +import games.dmg.fantasystick.passive.PathSpeed; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class PathSpeedSettingsTest { + @TempDir Path directory; + @ParameterizedTest @ValueSource(strings = {"0", "201", "-1", "1.0", "1e2", "true", "", " 20", "20 ", "999999999999999999999"}) + void invalidLeaseIsRejectedBeforePersistenceOrPublication(String raw) throws Exception { + try (var settings = new PathSpeedSettings(directory, PathSpeed.Settings.defaults())) { + assertThrows(IllegalArgumentException.class, () -> settings.set(PathSpeedSettings.LEASE, raw)); + assertEquals("20", settings.value(PathSpeedSettings.LEASE)); assertFalse(Files.exists(directory.resolve("config.yml"))); + } + } + @ParameterizedTest @ValueSource(strings = {"TRUE", "False", "yes", "1", "", " true"}) + void booleanIsStrictAndUnknownKeysCannotWrite(String raw) throws Exception { + try (var settings = new PathSpeedSettings(directory, PathSpeed.Settings.defaults())) { + assertThrows(IllegalArgumentException.class, () -> settings.set(PathSpeedSettings.ENABLED, raw)); + assertThrows(IllegalArgumentException.class, () -> settings.set("other-key", "true")); + assertTrue(settings.settings().enabled()); assertFalse(Files.exists(directory.resolve("config.yml"))); + } + } + @ParameterizedTest @ValueSource(ints = {1, 20, 200}) + void legalBoundsPersistBeforeExplicitPublication(int ticks) throws Exception { + Files.writeString(directory.resolve("config.yml"), "unrelated: [kept, values]\nroleplay-disabled: false\n"); + try (var settings = new PathSpeedSettings(directory, PathSpeed.Settings.defaults())) { + var saved = settings.set(PathSpeedSettings.LEASE, Integer.toString(ticks)).toCompletableFuture().get(5, TimeUnit.SECONDS); + assertEquals(20, settings.settings().leaseTicks()); + var yaml = Files.readString(directory.resolve("config.yml")); assertTrue(yaml.contains("path-speed-lease-ticks: " + ticks)); + assertTrue(yaml.contains("roleplay-disabled: false")); assertTrue(yaml.contains("kept")); assertTrue(yaml.contains("values")); + assertTrue(settings.publish(saved)); assertEquals(ticks, settings.settings().leaseTicks()); + assertFalse(settings.publish(saved)); + } + } + @Test void queuedIndependentKeysComposeAndOutOfOrderOrClosedPublicationCannotRevertPolicy() throws Exception { + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); + var count = new java.util.concurrent.atomic.AtomicInteger(); + var settings = new PathSpeedSettings(directory, PathSpeed.Settings.defaults(), (from, to) -> { + if (count.getAndIncrement() == 0) { + entered.countDown(); + try { if (!release.await(5, TimeUnit.SECONDS)) throw new java.io.IOException("Controlled save deadline"); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new java.io.IOException(interrupted); } + } + Files.move(from, to, java.nio.file.StandardCopyOption.ATOMIC_MOVE, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + }); + try { + var first = settings.set(PathSpeedSettings.ENABLED, "false"); assertTrue(entered.await(5, TimeUnit.SECONDS)); + var second = settings.set(PathSpeedSettings.LEASE, "17"); + assertEquals(PathSpeed.Settings.defaults(), settings.settings()); release.countDown(); + var a = first.toCompletableFuture().get(5, TimeUnit.SECONDS); var b = second.toCompletableFuture().get(5, TimeUnit.SECONDS); + assertEquals(new PathSpeed.Settings(false, 17), b.settings()); + assertTrue(settings.publish(b)); assertEquals(new PathSpeed.Settings(true, 17), settings.settings()); + assertTrue(settings.publish(a)); assertFalse(settings.publish(a)); assertEquals(new PathSpeed.Settings(false, 17), settings.settings()); + var text = Files.readString(directory.resolve("config.yml")); assertTrue(text.contains("path-speed-enabled: false")); assertTrue(text.contains("path-speed-lease-ticks: 17")); + settings.close(); settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS); + assertFalse(settings.publish(new PathSpeedSettings.Update(PathSpeed.Settings.defaults(), b.sequence() + 1, PathSpeedSettings.LEASE))); + assertThrows(java.util.concurrent.ExecutionException.class, () -> settings.set(PathSpeedSettings.LEASE, "2").toCompletableFuture().get(5, TimeUnit.SECONDS)); + } finally { release.countDown(); settings.close(); } + } + @Test void anAcknowledgedIndependentKeyCannotPublishAnEarlierUnpublishedKey() throws Exception { + try (var settings = new PathSpeedSettings(directory, PathSpeed.Settings.defaults())) { + var toggle = settings.set(PathSpeedSettings.ENABLED, "false").toCompletableFuture().get(5, TimeUnit.SECONDS); + var lease = settings.set(PathSpeedSettings.LEASE, "17").toCompletableFuture().get(5, TimeUnit.SECONDS); + assertTrue(settings.publish(lease)); + assertEquals(new PathSpeed.Settings(true, 17), settings.settings(), "Each native callback owns only its requested key"); + assertTrue(settings.publish(toggle), "A valid independent older-key callback is not superseded by another key"); + assertEquals(new PathSpeed.Settings(false, 17), settings.settings()); + assertFalse(settings.publish(toggle)); + } + } + @Test void replacementFailureRetainsFileAndRuntimeAndDeletesOwnedTemporaryFile() throws Exception { + Files.writeString(directory.resolve("config.yml"), "unrelated: retained\n"); + try (var settings = new PathSpeedSettings(directory, PathSpeed.Settings.defaults(), (from, to) -> { throw new java.io.IOException("Controlled replacement refusal"); })) { + assertThrows(java.util.concurrent.ExecutionException.class, () -> settings.set(PathSpeedSettings.ENABLED, "false").toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertEquals(PathSpeed.Settings.defaults(), settings.settings()); assertEquals("unrelated: retained\n", Files.readString(directory.resolve("config.yml"))); + try (var files = Files.list(directory)) { assertTrue(files.noneMatch(file -> file.getFileName().toString().endsWith(".tmp"))); } + } + } + @Test void busyLockUnsafeFileDuplicateKeysAndOversizedOrUnsafeYamlCannotChangePolicy() throws Exception { + try (var settings = new PathSpeedSettings(directory, PathSpeed.Settings.defaults()); + var channel = java.nio.channels.FileChannel.open(directory.resolve(".config.lock"), java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE); + var lock = channel.lock()) { + assertTrue(lock.isValid()); + assertThrows(java.util.concurrent.ExecutionException.class, () -> settings.set(PathSpeedSettings.LEASE, "1").toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertEquals(PathSpeed.Settings.defaults(), settings.settings()); + } + for (String contents : java.util.List.of("duplicate: 1\nduplicate: 2\n", "!!java.lang.ProcessBuilder {}\n", "not-a-map\n", "x".repeat(1_048_577))) { + Files.writeString(directory.resolve("config.yml"), contents); + try (var settings = new PathSpeedSettings(directory, PathSpeed.Settings.defaults())) { + assertThrows(java.util.concurrent.ExecutionException.class, () -> settings.set(PathSpeedSettings.ENABLED, "false").toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertEquals(PathSpeed.Settings.defaults(), settings.settings()); assertEquals(contents, Files.readString(directory.resolve("config.yml"))); + } + } + Files.delete(directory.resolve("config.yml")); Files.createDirectory(directory.resolve("config.yml")); + try (var settings = new PathSpeedSettings(directory, PathSpeed.Settings.defaults())) { + assertThrows(java.util.concurrent.ExecutionException.class, () -> settings.set(PathSpeedSettings.ENABLED, "false").toCompletableFuture().get(5, TimeUnit.SECONDS)); + } + } + @Test void startupLoadingRejectsQuotedBooleansFractionalAndOutOfBoundsLeases() { + assertEquals(PathSpeed.Settings.defaults(), PathSpeedSettings.load(null, null)); + assertEquals(new PathSpeed.Settings(false, 200), PathSpeedSettings.load(false, 200L)); + for (Object value : java.util.List.of("true", 1)) assertThrows(IllegalArgumentException.class, () -> PathSpeedSettings.load(value, 20)); + for (Object value : java.util.List.of("20", 20.0, 0, 201, Long.MAX_VALUE)) assertThrows(IllegalArgumentException.class, () -> PathSpeedSettings.load(true, value)); + } +} -- 2.54.0