Merge pull request 'feat(travel): grant finite native Speed I on Dirt Paths' (#15) from us-099-travel-faster-on-dirt-paths into main
This commit was merged in pull request #15.
This commit is contained in:
commit
22c46f6780
22 files changed
+1489
-23
No files matched your search
@@ -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 <key> [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.
|
||||
|
||||
@@ -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<Void> 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<Void> pathSpeedAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private java.util.concurrent.CompletionStage<Void> 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<Void>();
|
||||
adminActionDrain = writerClosed;
|
||||
Throwable handoffFailure = null;
|
||||
|
||||
@@ -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<String, Object>();
|
||||
@@ -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();
|
||||
|
||||
@@ -623,6 +623,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, 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<ControlInput>, Au
|
||||
private java.util.List<Component> 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.<games.dmg.fantasystick.adventure.FishingState.ItemStack>of():directory.inventory(character);var trophies=directory==null?java.util.List.<games.dmg.fantasystick.adventure.FishingState.Trophy>of():directory.trophies(character);var goods=rare==null?java.util.List.<games.dmg.fantasystick.shop.RareInventoryDirectory.Item>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<Component> 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);}
|
||||
|
||||
|
||||
@@ -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<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock, Path directory, Consumer<Throwable> 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<String, Object>();
|
||||
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<Void> drained() { return outbox.drained(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
}
|
||||
@@ -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<UUID, State> states = new HashMap<>();
|
||||
private final ArrayDeque<State> 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<EntityPotionEffectEvent> foreignEvents = new java.util.ArrayList<>();
|
||||
boolean foreignUncertain;
|
||||
final java.util.List<EntityPotionEffectEvent> 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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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<String, Long> publishedSequences = new java.util.HashMap<>();
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> 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<String> 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<Update> 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<String, Object> 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<Void> drained() { return drain.minimalCompletionStage(); }
|
||||
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drain.complete(null); }
|
||||
}
|
||||
@@ -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<games.dmg.fantasystick.persistence.PathSpeedSettings.Update> 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]);}
|
||||
|
||||
@@ -73,6 +73,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private final Map<UUID, Attempt> pending = new HashMap<>();
|
||||
private final Map<UUID, Attempt> owned = new HashMap<>();
|
||||
private final Map<UUID, Attempt> active = new HashMap<>();
|
||||
private final java.util.ArrayDeque<Active> activeRotation = new java.util.ArrayDeque<>();
|
||||
private final Map<UUID, UUID> characterRecoveries = new HashMap<>();
|
||||
private final java.util.Set<UUID> recoveryRecipients = new java.util.HashSet<>();
|
||||
private final java.util.ArrayDeque<UUID> 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<ActiveCharacter> 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<ActiveCharacter>();
|
||||
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<Active> 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); }
|
||||
|
||||
@@ -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 <key> [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
|
||||
|
||||
+3
-2
@@ -20,7 +20,7 @@ class AdminActionConfigurationCatalogNativeTest {
|
||||
|
||||
private static final List<List<String>> 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<String[]> 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"},
|
||||
|
||||
@@ -418,6 +418,8 @@ class CharacterBookNativeTest {
|
||||
var categorized = new java.util.LinkedHashMap<String, java.util.List<String>>();
|
||||
var indexPageByAbility = new java.util.HashMap<String, Integer>();
|
||||
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];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Runnable> 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");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<FantasyStickLifecycleNativeTest.Actor>();
|
||||
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<java.util.UUID>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<com.google.gson.JsonObject> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user