Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27f38818ae | ||
|
|
624deb938d | ||
|
|
232b2a190d |
No files matched your search
@@ -49,6 +49,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.ability.AbilityRegistry abilityRegistry;
|
||||
private games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
|
||||
private games.dmg.fantasystick.ability.Fireball fireball;
|
||||
private games.dmg.fantasystick.ability.Revive revive;
|
||||
private games.dmg.fantasystick.persistence.RevivalStore revivals;
|
||||
private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy;
|
||||
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
|
||||
|
||||
@@ -116,7 +118,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
notice -> notifyPlayer(generation, notice), diagnostics, presentation, adventureSettings::enabled, gameModes);
|
||||
abilityRegistry = new games.dmg.fantasystick.ability.AbilityRegistry();
|
||||
fireball = new games.dmg.fantasystick.ability.Fireball(fireballSettings);
|
||||
revivals = new games.dmg.fantasystick.persistence.RevivalStore(getDataFolder().toPath());
|
||||
revive = new games.dmg.fantasystick.ability.Revive(characters, revivals, Clock.systemUTC(), nextTick,
|
||||
(size, title) -> getServer().createInventory(null, size, title),
|
||||
problem -> getLogger().log(Level.WARNING, "Revive transition failed", problem));
|
||||
abilityRegistry.register(fireball.definition());
|
||||
abilityRegistry.register(revive.definition());
|
||||
sessions.attachAbilityRegistry(abilityRegistry);
|
||||
vitals = new games.dmg.fantasystick.session.SessionVitals();
|
||||
sessions.attachVitals(vitals);
|
||||
@@ -172,6 +179,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(combatExperience, this);
|
||||
combatExperience.install(this);
|
||||
getServer().getPluginManager().registerEvents(fireball, this);
|
||||
getServer().getPluginManager().registerEvents(revive, this);
|
||||
getServer().getPluginManager().registerEvents(localBasicAttack, this);
|
||||
getServer().getPluginManager().registerEvents(nutritionPolicy, this);
|
||||
getServer().getPluginManager().registerEvents(books, this);
|
||||
@@ -181,7 +189,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
sessionVisuals.install(this);
|
||||
recipeRegistered = getServer().addRecipe(new CharacterSheets().recipe());
|
||||
if (!recipeRegistered) throw new IllegalStateException("Character-sheet recipe key is already registered");
|
||||
for (var player : getServer().getOnlinePlayers()) sessions.arrive(player);
|
||||
for (var player : getServer().getOnlinePlayers()) { sessions.arrive(player); revive.reconcile(player); }
|
||||
} catch (RuntimeException | LinkageError failure) {
|
||||
try { onDisable(); }
|
||||
catch (RuntimeException | LinkageError cleanupFailure) { failure.addSuppressed(cleanupFailure); }
|
||||
@@ -215,6 +223,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingCombatExperience = combatExperience; combatExperience = null;
|
||||
var closingAbilityEngine = abilityEngine; abilityEngine = null;
|
||||
var closingFireball = fireball; fireball = null;
|
||||
var closingRevive = revive; revive = null;
|
||||
var closingRevivals = revivals; revivals = null;
|
||||
abilityRegistry = null;
|
||||
var closingNutrition = nutritionPolicy; nutritionPolicy = null;
|
||||
// Retire client resources independently of successful ordinary-equipment restoration.
|
||||
@@ -229,6 +239,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingBasicAttack != null) cleanup("basic attack", closingBasicAttack::close);
|
||||
if (closingCombatExperience != null) cleanup("combat experience", closingCombatExperience::close);
|
||||
if (closingFireball != null) cleanup("Fireball", closingFireball::close);
|
||||
if (closingRevive != null) cleanup("Revive", closingRevive::close);
|
||||
if (closingAbilityEngine != null) cleanup("ability engine", closingAbilityEngine::close);
|
||||
if (closingNutrition != null) cleanup("nutrition policy", closingNutrition::close);
|
||||
if (closingVisuals != null) cleanup("session particles", closingVisuals::close);
|
||||
@@ -244,6 +255,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
unregister(closingPartyCombat);
|
||||
unregister(closingCombatExperience);
|
||||
unregister(closingFireball);
|
||||
unregister(closingRevive);
|
||||
unregister(closingBasicAttack);
|
||||
unregister(closingNutrition);
|
||||
unregister(closingBooks);
|
||||
@@ -256,6 +268,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
recipeRegistered = false; // Retain ownership for a later retry if removal failed.
|
||||
});
|
||||
if (closingCharacters != null) cleanup("character storage", closingCharacters::close);
|
||||
if (closingRevivals != null) cleanup("revival storage", closingRevivals::close);
|
||||
// Never await IO on the server thread or close receipts ahead of accepted preparations.
|
||||
if (closingReceipts != null) cleanup("equipment receipt drain", () -> {
|
||||
if (closingSessions == null) closingReceipts.close();
|
||||
|
||||
@@ -17,7 +17,8 @@ public record AbilityDefinition(String id, String name, Material icon, List<Stri
|
||||
ControlInput.Gesture gesture, Duration cooldown, Map<String, BigDecimal> parameters,
|
||||
TargetPolicy targetPolicy, AbilityEffect effect) {
|
||||
public enum Activation { IMMEDIATE, DELAYED, PROJECTILE, AREA, SELECTION }
|
||||
public enum TargetPolicy { SELF, HOSTILE, PROJECTILE_HOSTILE, ALLY, PARTY, AREA, SELECTION, EXCEPTIONAL_NONCOMBAT }
|
||||
public enum TargetPolicy { SELF, HOSTILE, PROJECTILE_HOSTILE, ALLY, PARTY, AREA, SELECTION, EXCEPTIONAL_NONCOMBAT,
|
||||
EXCEPTIONAL_NONCOMBAT_SELECTION }
|
||||
private static final Pattern ID = Pattern.compile("[a-z][a-z0-9_]{0,63}");
|
||||
private static final Pattern PARAMETER = Pattern.compile("[a-z][a-z0-9_-]{0,63}");
|
||||
|
||||
|
||||
@@ -3,5 +3,7 @@ package games.dmg.fantasystick.ability;
|
||||
/** Focused native effect invoked only after shared persistence, cooldown and target authorization. */
|
||||
@FunctionalInterface
|
||||
public interface AbilityEffect {
|
||||
/** Effect-specific initial eligibility checked before the shared cooldown commit. */
|
||||
default boolean accepts(games.dmg.fantasystick.session.ControlInput input) { return true; }
|
||||
void activate(AbilityExecution execution);
|
||||
}
|
||||
@@ -81,6 +81,21 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
() -> characters.assignAbility(input.session().character(), expectedRevision, ability, slot));
|
||||
}
|
||||
|
||||
public CompletableFuture<Mutation> unlearn(ControlInput input, String ability, BigInteger expectedRevision) {
|
||||
Objects.requireNonNull(input); Objects.requireNonNull(expectedRevision);
|
||||
if (closed || registry.find(ability).isEmpty() || input.kind() != ControlInput.Kind.BOOK || !input.stillCurrent()) {
|
||||
return CompletableFuture.completedFuture(new Mutation(CharacterStorage.AbilityMutationStatus.UNAVAILABLE, null));
|
||||
}
|
||||
return mutation(input, ability, expectedRevision,
|
||||
() -> characters.unlearnAbility(input.session().character(), expectedRevision, ability))
|
||||
.thenApply(mutation -> {
|
||||
if (mutation.record() != null && !mutation.record().learned().contains(ability)) {
|
||||
remove(input.session().character(), ability);
|
||||
}
|
||||
return mutation;
|
||||
});
|
||||
}
|
||||
|
||||
private CompletableFuture<Mutation> mutation(ControlInput input, String ability, BigInteger expectedRevision,
|
||||
java.util.function.Supplier<CompletableFuture<CharacterStorage.AbilityMutation>> operation) {
|
||||
var result = new CompletableFuture<Mutation>();
|
||||
@@ -123,7 +138,13 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
input.player().sendMessage("" + definition.name() + " is ready in "
|
||||
+ Math.max(1, Duration.between(now, existing).toSeconds()) + " seconds."); return;
|
||||
}
|
||||
if (!eligible(definition.targetPolicy(), input, input.target())) {
|
||||
boolean accepted;
|
||||
try { accepted = eligible(definition.targetPolicy(), input, input.target()) && definition.effect().accepts(input); }
|
||||
catch (RuntimeException invalid) {
|
||||
report(input.session().character(), definition.id(), invalid);
|
||||
input.player().sendMessage("That target could not be validated for " + definition.name() + "."); return;
|
||||
}
|
||||
if (!accepted) {
|
||||
input.player().sendMessage("That target is not eligible for " + definition.name() + "."); return;
|
||||
}
|
||||
var key = new Key(input.session().character(), definition.id());
|
||||
@@ -220,6 +241,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
case AREA -> target == null || target instanceof LivingEntity && (!(target instanceof Player player) || fantasy(player));
|
||||
case SELECTION -> target == null || target instanceof LivingEntity && (!(target instanceof Player player) || fantasy(player));
|
||||
case EXCEPTIONAL_NONCOMBAT -> target instanceof Player && target != input.player();
|
||||
case EXCEPTIONAL_NONCOMBAT_SELECTION -> target == null || target instanceof Player && target != input.player();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -305,6 +327,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
}
|
||||
@Override public void report(AbilityReport.Type type, Entity target, java.math.BigDecimal amount) {
|
||||
if (!stillCurrent() || definition.targetPolicy() == AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT
|
||||
|| definition.targetPolicy() == AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT_SELECTION
|
||||
|| target == null || !AbilityEngine.this.eligible(definition.targetPolicy(), input, target)) return;
|
||||
reports.accept(new AbilityReport(type, key.character(), input.session().session(), target.getUniqueId(), amount, clock.instant()));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.items.TailBooks;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.RevivalStore;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
/** Revive target selection, contained popup and proof-based Tail conversion. */
|
||||
public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
public interface PopupFactory { Inventory create(int size, Component title); }
|
||||
private static final Duration DEATH_AGE = Duration.ofHours(24);
|
||||
private static final Duration VIEW_TIME = Duration.ofMinutes(5);
|
||||
private static final double RANGE = 12;
|
||||
private record Candidate(int slot, ItemStack captured, TailBooks.Identity identity) { }
|
||||
private record Choice(Candidate candidate, CharacterRecord record) { }
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final RevivalStore reservations;
|
||||
private final Clock clock;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final PopupFactory popups;
|
||||
private final Consumer<Throwable> failures;
|
||||
private final TailBooks tails = new TailBooks();
|
||||
private final CharacterSheets sheets = new CharacterSheets();
|
||||
private final Map<Inventory, View> inventories = new IdentityHashMap<>();
|
||||
private final Map<UUID, View> targets = new LinkedHashMap<>();
|
||||
private boolean closed;
|
||||
|
||||
private final class View implements AutoCloseable {
|
||||
final AbilityExecution execution;
|
||||
final Player target;
|
||||
final Inventory inventory;
|
||||
final Map<Integer, Choice> choices;
|
||||
boolean selecting;
|
||||
boolean finished;
|
||||
View(AbilityExecution execution, Player target, Inventory inventory, Map<Integer, Choice> choices) {
|
||||
this.execution = execution; this.target = target; this.inventory = inventory; this.choices = choices;
|
||||
}
|
||||
@Override public void close() {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
inventories.remove(inventory, this); targets.remove(target.getUniqueId(), this);
|
||||
inventory.clear();
|
||||
try { target.closeInventory(); } catch (RuntimeException failure) { report(failure); }
|
||||
}
|
||||
}
|
||||
|
||||
public Revive(CharacterStorage characters, RevivalStore reservations, Clock clock, Consumer<Runnable> nextTick,
|
||||
PopupFactory popups, Consumer<Throwable> failures) {
|
||||
this.characters = Objects.requireNonNull(characters); this.reservations = Objects.requireNonNull(reservations);
|
||||
this.clock = Objects.requireNonNull(clock); this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.popups = Objects.requireNonNull(popups); this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
return new AbilityDefinition("revive", "Revive", Material.ECHO_SHARD,
|
||||
List.of("Let another player continue one fallen character.", "A valid cast spends the daily opportunity."),
|
||||
"Use the equipped shard while aiming at another player within 12 blocks.",
|
||||
Map.of(Stat.WIS, BigInteger.valueOf(30), Stat.CHR, BigInteger.valueOf(24), Stat.CON, BigInteger.valueOf(12)),
|
||||
BigInteger.ONE, AbilityDefinition.Activation.SELECTION, ControlInput.Gesture.USE, Duration.ofHours(24),
|
||||
Map.of("range", BigDecimal.valueOf(12), "selection-time", BigDecimal.valueOf(300),
|
||||
"death-age", BigDecimal.valueOf(86400)),
|
||||
AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT_SELECTION, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) { return !closed && target(input) != null; }
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) { execution.finish(); return; }
|
||||
Player target = target(execution.input());
|
||||
if (target == null) {
|
||||
execution.input().player().sendMessage("Revive found no eligible player within 12 blocks and line of sight.");
|
||||
execution.finish(); return;
|
||||
}
|
||||
var captured = candidates(target);
|
||||
if (captured.isEmpty()) {
|
||||
execution.input().player().sendMessage("That player has no direct-inventory Tail eligible for Revive.");
|
||||
execution.finish(); return;
|
||||
}
|
||||
var loads = captured.stream().map(candidate -> characters.find(candidate.identity().characterId())).toList();
|
||||
CompletableFuture.allOf(loads.toArray(CompletableFuture[]::new)).whenComplete((ignored, failure) -> schedule(() -> {
|
||||
if (failure != null) { report(failure); execution.input().player().sendMessage("Revive could not inspect those Tails safely."); execution.finish(); return; }
|
||||
if (closed || !execution.stillCurrent() || !target.isOnline()) { execution.finish(); return; }
|
||||
var eligible = new ArrayList<Choice>();
|
||||
Instant now = clock.instant();
|
||||
for (int index = 0; index < captured.size(); index++) {
|
||||
var found = loads.get(index).join();
|
||||
if (found.isEmpty()) continue;
|
||||
var record = found.orElseThrow();
|
||||
var candidate = captured.get(index);
|
||||
if (eligible(record, candidate.identity(), now) && unchanged(target, candidate)) eligible.add(new Choice(candidate, record));
|
||||
}
|
||||
if (eligible.isEmpty()) {
|
||||
execution.input().player().sendMessage("That player has no current Tail younger than 24 hours.");
|
||||
execution.finish(); return;
|
||||
}
|
||||
open(execution, target, eligible, now);
|
||||
}));
|
||||
}
|
||||
|
||||
private Player target(ControlInput input) {
|
||||
Player caster = input.player();
|
||||
Entity supplied = input.target();
|
||||
Player selected = supplied instanceof Player player ? player : null;
|
||||
if (selected == null) {
|
||||
Location eye = caster.getEyeLocation();
|
||||
var hit = caster.getWorld().rayTraceEntities(eye, eye.getDirection(), RANGE,
|
||||
entity -> entity instanceof Player && entity != caster);
|
||||
if (hit != null && hit.getHitEntity() instanceof Player player) selected = player;
|
||||
}
|
||||
if (selected == null || selected == caster || !selected.isOnline() || selected.getWorld() != caster.getWorld()
|
||||
|| selected.getLocation().distanceSquared(caster.getLocation()) > RANGE * RANGE
|
||||
|| !caster.hasLineOfSight(selected)) return null;
|
||||
return selected;
|
||||
}
|
||||
|
||||
private List<Candidate> candidates(Player target) {
|
||||
var result = new ArrayList<Candidate>();
|
||||
for (int slot = 0; slot <= 40; slot++) {
|
||||
if (slot > 35 && slot != 40) continue;
|
||||
ItemStack item = target.getInventory().getItem(slot);
|
||||
if (item == null || item.isEmpty()) continue;
|
||||
var identity = tails.identify(item);
|
||||
if (identity.isPresent()) result.add(new Candidate(slot, item.clone(), identity.orElseThrow()));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private boolean eligible(CharacterRecord record, TailBooks.Identity identity, Instant now) {
|
||||
if (!record.dead() || !record.id().equals(identity.characterId()) || !Objects.equals(record.deathId(), identity.deathId())) return false;
|
||||
Duration age = Duration.between(record.diedAt(), now);
|
||||
return !age.isNegative() && age.compareTo(DEATH_AGE) < 0;
|
||||
}
|
||||
|
||||
private boolean unchanged(Player target, Candidate candidate) {
|
||||
ItemStack current = target.getInventory().getItem(candidate.slot());
|
||||
return current != null && current.equals(candidate.captured());
|
||||
}
|
||||
|
||||
private void open(AbilityExecution execution, Player target, List<Choice> eligible, Instant now) {
|
||||
View old = targets.get(target.getUniqueId()); if (old != null) old.execution.finish();
|
||||
int size = Math.min(54, Math.max(9, ((eligible.size() + 8) / 9) * 9));
|
||||
Inventory inventory = popups.create(size, Component.text("Choose a Tail to Revive"));
|
||||
var choices = new LinkedHashMap<Integer, Choice>();
|
||||
for (int index = 0; index < eligible.size() && index < size; index++) {
|
||||
Choice choice = eligible.get(index); ItemStack display = choice.candidate().captured().clone(); display.setAmount(1);
|
||||
var meta = display.getItemMeta();
|
||||
long seconds = Math.max(0, Duration.between(now, choice.record().diedAt().plus(DEATH_AGE)).toSeconds());
|
||||
meta.lore(List.of(Component.text("Level " + choice.record().level()),
|
||||
Component.text("Eligible for " + seconds + " more seconds"), Component.text("Click to continue this character")));
|
||||
display.setItemMeta(meta); inventory.setItem(index, display); choices.put(index, choice);
|
||||
}
|
||||
var view = new View(execution, target, inventory, Map.copyOf(choices));
|
||||
inventories.put(inventory, view); targets.put(target.getUniqueId(), view); execution.own(view);
|
||||
target.openInventory(inventory);
|
||||
execution.later(VIEW_TIME, execution::finish);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void click(org.bukkit.event.inventory.InventoryClickEvent event) {
|
||||
var view = inventories.get(event.getView().getTopInventory());
|
||||
if (view == null) return;
|
||||
event.setCancelled(true);
|
||||
if (event.getWhoClicked() != view.target || event.getRawSlot() < 0 || event.getRawSlot() >= view.inventory.getSize()) return;
|
||||
Choice choice = view.choices.get(event.getRawSlot());
|
||||
if (choice == null || view.selecting) return;
|
||||
view.selecting = true; inventories.remove(view.inventory, view); targets.remove(view.target.getUniqueId(), view);
|
||||
view.inventory.clear(); view.target.closeInventory();
|
||||
confirm(view, choice);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void drag(org.bukkit.event.inventory.InventoryDragEvent event) {
|
||||
var view = inventories.get(event.getView().getTopInventory());
|
||||
if (view != null && event.getRawSlots().stream().anyMatch(slot -> slot < view.inventory.getSize())) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void close(org.bukkit.event.inventory.InventoryCloseEvent event) {
|
||||
var view = inventories.get(event.getView().getTopInventory());
|
||||
if (view != null && !view.selecting) view.execution.finish();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void quit(org.bukkit.event.player.PlayerQuitEvent event) {
|
||||
var targetView = targets.get(event.getPlayer().getUniqueId()); if (targetView != null) targetView.execution.finish();
|
||||
for (var view : List.copyOf(inventories.values())) {
|
||||
if (view.execution.input().player() == event.getPlayer()) view.execution.finish();
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void join(org.bukkit.event.player.PlayerJoinEvent event) { reconcile(event.getPlayer()); }
|
||||
|
||||
private void confirm(View view, Choice choice) {
|
||||
if (closed || !view.execution.stillCurrent() || !view.target.isOnline() || !unchanged(view.target, choice.candidate())) {
|
||||
view.execution.finish(); return;
|
||||
}
|
||||
characters.find(choice.record().id()).whenComplete((found, failure) -> schedule(() -> {
|
||||
if (failure != null) { report(failure); view.execution.input().player().sendMessage("Revive could not recheck that Tail; no item changed."); view.execution.finish(); return; }
|
||||
if (closed || !view.execution.stillCurrent() || !view.target.isOnline() || found.isEmpty()
|
||||
|| !eligible(found.orElseThrow(), choice.candidate().identity(), clock.instant())
|
||||
|| !unchanged(view.target, choice.candidate())) {
|
||||
view.execution.input().player().sendMessage("That Tail is no longer eligible; no item changed.");
|
||||
view.execution.finish(); return;
|
||||
}
|
||||
reserve(view, choice.candidate(), found.orElseThrow());
|
||||
}));
|
||||
}
|
||||
|
||||
private void reserve(View view, Candidate candidate, CharacterRecord dead) {
|
||||
ItemStack source = view.target.getInventory().getItem(candidate.slot());
|
||||
int destination = source.getAmount() == 1 ? -1 : emptyDirectSlot(view.target, candidate.slot());
|
||||
if (source.getAmount() > 1 && destination < 0) {
|
||||
view.target.sendMessage("Revive needs one empty direct inventory slot to preserve the copied Tails.");
|
||||
view.execution.finish(); return;
|
||||
}
|
||||
var reservation = new RevivalStore.Reservation(UUID.randomUUID(), view.execution.character().id(),
|
||||
view.target.getUniqueId(), dead.id(), dead.deathId(), clock.instant().truncatedTo(ChronoUnit.SECONDS),
|
||||
candidate.slot(), source.getAmount(), destination);
|
||||
reservations.reserve(reservation).whenComplete((reserved, failure) -> schedule(() -> {
|
||||
if (failure != null) { report(failure); view.execution.input().player().sendMessage("Revive reservation could not be saved; no Tail changed."); view.execution.finish(); return; }
|
||||
if (reserved.isEmpty() || closed || !view.execution.stillCurrent() || !view.target.isOnline()
|
||||
|| !unchanged(view.target, candidate)) {
|
||||
view.execution.input().player().sendMessage("Revive selection became stale after reservation; no Tail was changed.");
|
||||
view.execution.finish(); return;
|
||||
}
|
||||
deliver(view, dead, reservation);
|
||||
}));
|
||||
}
|
||||
|
||||
private int emptyDirectSlot(Player target, int source) {
|
||||
for (int slot = 0; slot <= 40; slot++) {
|
||||
if (slot > 35 && slot != 40 || slot == source) continue;
|
||||
ItemStack item = target.getInventory().getItem(slot);
|
||||
if (item == null || item.isEmpty()) return slot;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private void deliver(View view, CharacterRecord dead, RevivalStore.Reservation reservation) {
|
||||
try {
|
||||
var provisional = new CharacterRecord(dead.id(), dead.name(), dead.revision(), dead.stats(), dead.level(), dead.experience(),
|
||||
dead.unspentPoints(), dead.maximumHealth(), dead.learned(), dead.loadout(), dead.cooldowns(), dead.restrictedKills(),
|
||||
false, dead.deathId(), dead.diedAt());
|
||||
ItemStack sheet = sheets.create(provisional);
|
||||
ItemStack source = view.target.getInventory().getItem(reservation.sourceSlot());
|
||||
if (reservation.sourceAmount() == 1) view.target.getInventory().setItem(reservation.sourceSlot(), sheet);
|
||||
else {
|
||||
view.target.getInventory().setItem(reservation.destinationSlot(), sheet);
|
||||
var remaining = source.clone(); remaining.setAmount(reservation.sourceAmount() - 1);
|
||||
view.target.getInventory().setItem(reservation.sourceSlot(), remaining);
|
||||
}
|
||||
view.target.updateInventory();
|
||||
} catch (RuntimeException uncertain) {
|
||||
report(uncertain); view.target.sendMessage("Revive item delivery is uncertain; retained proof requires recovery.");
|
||||
view.execution.finish(); return;
|
||||
}
|
||||
var nativeLimit = games.dmg.fantasystick.session.NativeHealthLimit.read();
|
||||
characters.restoreDeathRecord(reservation.character(), reservation.death(), nativeLimit)
|
||||
.whenComplete((restored, failure) -> schedule(() -> {
|
||||
if (failure != null || restored.isEmpty()) {
|
||||
if (failure != null) report(failure);
|
||||
view.target.sendMessage("The Tail changed, but character restoration is pending recovery; do not move the sheet.");
|
||||
view.execution.finish(); return;
|
||||
}
|
||||
if (installFinalSheet(view.target, restored.orElseThrow())) {
|
||||
reservations.clear(reservation.character(), reservation.id()).exceptionally(failure2 -> { report(failure2); return false; });
|
||||
view.target.sendMessage("Restored character sheet: " + restored.orElseThrow().name() + ".");
|
||||
view.execution.input().player().sendMessage("Revive restored " + restored.orElseThrow().name() + ".");
|
||||
} else view.target.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");
|
||||
view.execution.finish();
|
||||
}));
|
||||
}
|
||||
|
||||
private boolean installFinalSheet(Player target, CharacterRecord record) {
|
||||
int slot = uniqueSheetSlot(target, record.id());
|
||||
if (slot < 0) return false;
|
||||
target.getInventory().setItem(slot, sheets.create(record)); target.updateInventory(); return true;
|
||||
}
|
||||
|
||||
private int uniqueSheetSlot(Player target, UUID character) {
|
||||
int found = -1;
|
||||
for (int slot = 0; slot <= 40; slot++) {
|
||||
if (slot > 35 && slot != 40) continue;
|
||||
if (sheets.identify(target.getInventory().getItem(slot)).filter(character::equals).isEmpty()) continue;
|
||||
if (found >= 0) return -2;
|
||||
found = slot;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
public void reconcile(Player target) {
|
||||
if (closed || !target.isOnline()) return;
|
||||
reservations.target(target.getUniqueId()).whenComplete((found, failure) -> {
|
||||
if (failure != null) { report(failure); return; }
|
||||
for (var reservation : found) characters.find(reservation.character()).whenComplete((record, loadFailure) -> schedule(() -> {
|
||||
if (loadFailure != null) { report(loadFailure); return; }
|
||||
if (closed || !target.isOnline() || record.isEmpty()) return;
|
||||
reconcile(target, reservation, record.orElseThrow());
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
private void reconcile(Player target, RevivalStore.Reservation reservation, CharacterRecord record) {
|
||||
int foundSheet = uniqueSheetSlot(target, reservation.character());
|
||||
boolean hasSheet = foundSheet >= 0;
|
||||
ItemStack source = target.getInventory().getItem(reservation.sourceSlot());
|
||||
boolean unchangedTail = source != null && source.getAmount() == reservation.sourceAmount()
|
||||
&& tails.identify(source).filter(id -> id.characterId().equals(reservation.character()) && id.deathId().equals(reservation.death())).isPresent();
|
||||
boolean consumedTail = reservation.sourceAmount() == 1 ? hasSheet
|
||||
: source != null && source.getAmount() == reservation.sourceAmount() - 1
|
||||
&& tails.identify(source).filter(id -> id.deathId().equals(reservation.death())).isPresent();
|
||||
if (!record.dead() && hasSheet && installFinalSheet(target, record)) {
|
||||
reservations.clear(reservation.character(), reservation.id()); return;
|
||||
}
|
||||
if (record.dead() && unchangedTail && !hasSheet) {
|
||||
reservations.clear(reservation.character(), reservation.id()); return;
|
||||
}
|
||||
if (record.dead() && hasSheet && (consumedTail || unchangedTail && reservation.sourceAmount() > 1)) {
|
||||
if (unchangedTail && reservation.sourceAmount() > 1) {
|
||||
var remaining = source.clone(); remaining.setAmount(source.getAmount() - 1);
|
||||
target.getInventory().setItem(reservation.sourceSlot(), remaining); target.updateInventory();
|
||||
}
|
||||
var nativeLimit = games.dmg.fantasystick.session.NativeHealthLimit.read();
|
||||
characters.restoreDeathRecord(reservation.character(), reservation.death(), nativeLimit)
|
||||
.whenComplete((restored, failure) -> schedule(() -> {
|
||||
if (failure != null) { report(failure); return; }
|
||||
if (restored.isPresent() && installFinalSheet(target, restored.orElseThrow())) {
|
||||
reservations.clear(reservation.character(), reservation.id());
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
private void schedule(Runnable action) {
|
||||
try { nextTick.accept(action); } catch (RuntimeException rejected) { report(rejected); }
|
||||
}
|
||||
private void report(Throwable failure) { try { failures.accept(failure); } catch (RuntimeException ignored) { } }
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
for (var view : List.copyOf(inventories.values())) view.execution.finish();
|
||||
inventories.clear(); targets.clear();
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
private static final net.kyori.adventure.key.Key STATS = net.kyori.adventure.key.Key.key("fantasystick", "character_stat");
|
||||
private static final net.kyori.adventure.key.Key ABILITY_UNLOCK = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlock");
|
||||
private static final net.kyori.adventure.key.Key ABILITY_ASSIGN = net.kyori.adventure.key.Key.key("fantasystick", "ability_assign");
|
||||
private static final net.kyori.adventure.key.Key ABILITY_UNLEARN = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlearn");
|
||||
private static final java.util.regex.Pattern PAGE_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([01])\"");
|
||||
private static final java.util.regex.Pattern STAT_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):(STR|DEX|CON|INT|WIS|CHR)\"");
|
||||
private static final java.util.regex.Pattern ABILITY_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9_]{0,63})(?::([0-6]))?\"");
|
||||
@@ -178,11 +179,13 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
if (!learned && stats && record.unspentPoints().compareTo(definition.pointCost()) >= 0) {
|
||||
pages.add(Component.text("Ability action\n\n").append(abilityButton("Learn", ABILITY_UNLOCK, view, definition.id(), null)));
|
||||
} else if (learned) {
|
||||
Component action = Component.text("Assign " + definition.name() + "\n\nSlot: ");
|
||||
Component action = Component.text("Assign ability\n\nSlot:\n");
|
||||
for (int slot = 0; slot < 7; slot++) {
|
||||
if (slot > 0) action = action.append(Component.space());
|
||||
if (slot > 0) action = action.append(slot == 4 ? Component.newline() : Component.space());
|
||||
action = action.append(abilityButton(Integer.toString(slot + 3), ABILITY_ASSIGN, view, definition.id(), slot));
|
||||
}
|
||||
action = action.append(Component.text("\n\nRefund: "))
|
||||
.append(abilityButton("Unlearn", ABILITY_UNLEARN, view, definition.id(), null));
|
||||
pages.add(action);
|
||||
}
|
||||
return java.util.List.copyOf(pages);
|
||||
@@ -285,7 +288,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
public void abilityAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
|
||||
boolean unlock = ABILITY_UNLOCK.equals(event.getIdentifier());
|
||||
boolean assign = ABILITY_ASSIGN.equals(event.getIdentifier());
|
||||
if (closed || abilityEngine == null || !unlock && !assign
|
||||
boolean unlearn = ABILITY_UNLEARN.equals(event.getIdentifier());
|
||||
if (closed || abilityEngine == null || !unlock && !assign && !unlearn
|
||||
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|
||||
|| event.getTag() == null) return;
|
||||
var player = connection.getPlayer();
|
||||
@@ -295,10 +299,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
if (raw.length() > 120) return;
|
||||
var match = ABILITY_PAYLOAD.matcher(raw);
|
||||
if (!match.matches() || !view.nonce().equals(match.group(1)) || !view.ids().contains(match.group(2))
|
||||
|| unlock != (match.group(3) == null)
|
||||
|| assign != (match.group(3) != null)
|
||||
|| !abilityViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
|
||||
String ability = match.group(2);
|
||||
var operation = unlock ? abilityEngine.purchase(view.input(), ability, view.revision())
|
||||
: unlearn ? abilityEngine.unlearn(view.input(), ability, view.revision())
|
||||
: abilityEngine.assign(view.input(), ability, Integer.parseInt(match.group(3)), view.revision());
|
||||
operation.whenComplete((mutation, failure) -> {
|
||||
if (closed || !view.input().stillCurrent()) return;
|
||||
@@ -307,19 +312,22 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
player.sendMessage("Ability change could not be confirmed; reopen the character after session recovery.");
|
||||
return;
|
||||
}
|
||||
player.sendMessage(abilityMessage(unlock, ability, mutation.status()));
|
||||
player.sendMessage(abilityMessage(unlock, unlearn, ability, mutation.status()));
|
||||
load(view.input(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
private static String abilityMessage(boolean unlock, String ability, CharacterStorage.AbilityMutationStatus status) {
|
||||
private static String abilityMessage(boolean unlock, boolean unlearn, String ability,
|
||||
CharacterStorage.AbilityMutationStatus status) {
|
||||
return switch (status) {
|
||||
case APPLIED -> unlock ? "Learned " + ability + "." : "Assigned " + ability + ".";
|
||||
case APPLIED -> unlock ? "Learned " + ability + "."
|
||||
: unlearn ? "Unlearned " + ability + " and refunded 1 point." : "Assigned " + ability + ".";
|
||||
case UNCHANGED -> ability + " is already assigned there.";
|
||||
case REQUIREMENTS -> "The purchased base-stat requirements are not met.";
|
||||
case NO_POINTS -> "This character does not have enough unspent points.";
|
||||
case ALREADY_LEARNED -> "This character already knows " + ability + ".";
|
||||
case NOT_LEARNED -> "Learn " + ability + " before assigning it.";
|
||||
case NOT_LEARNED -> unlearn ? "This character no longer knows " + ability + "."
|
||||
: "Learn " + ability + " before assigning it.";
|
||||
case STALE -> "That character page is stale; reopen it before changing abilities.";
|
||||
case COOLDOWN -> "That ability is still on cooldown.";
|
||||
case UNAVAILABLE -> "That character or ability is unavailable.";
|
||||
|
||||
@@ -326,6 +326,26 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
/** Refund one learned ability exactly once, clear every equipped reference and retain its deadline. */
|
||||
public CompletableFuture<AbilityMutation> unlearnAbility(UUID characterId, BigInteger expectedRevision, String ability) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(ability);
|
||||
if (ability.isBlank()) throw new IllegalArgumentException("Invalid ability refund");
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) return new AbilityMutation(AbilityMutationStatus.UNAVAILABLE, Optional.empty());
|
||||
var old = found.orElseThrow();
|
||||
if (!old.revision().equals(expectedRevision)) return new AbilityMutation(AbilityMutationStatus.STALE, Optional.of(old));
|
||||
if (!old.learned().contains(ability)) return new AbilityMutation(AbilityMutationStatus.NOT_LEARNED, Optional.of(old));
|
||||
var learned = new java.util.HashSet<>(old.learned()); learned.remove(ability);
|
||||
var loadout = old.loadout().stream().map(assigned -> assigned.equals(ability) ? "" : assigned).toList();
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints().add(BigInteger.ONE), old.health(), learned, loadout, old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
}
|
||||
|
||||
/** Move one learned ability to one of the seven selectable slots without duplicating it. */
|
||||
public CompletableFuture<AbilityMutation> assignAbility(UUID characterId, BigInteger expectedRevision,
|
||||
String ability, int slot) {
|
||||
@@ -469,8 +489,15 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> restoreDeathRecord(UUID characterId, UUID deathId) {
|
||||
return restoreDeathRecord(characterId, deathId, null);
|
||||
}
|
||||
|
||||
/** Native Revive supplies the current plugin-controlled health ceiling captured on the server thread. */
|
||||
public CompletableFuture<Optional<CharacterRecord>> restoreDeathRecord(UUID characterId, UUID deathId,
|
||||
java.math.BigDecimal nativeLimit) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Objects.requireNonNull(deathId);
|
||||
if (nativeLimit != null && nativeLimit.signum() <= 0) throw new IllegalArgumentException("Invalid revival health limit");
|
||||
Instant selectedAt = clock.instant();
|
||||
return submit(() -> {
|
||||
Optional<CharacterRecord> found = repository.load(characterId);
|
||||
@@ -480,9 +507,10 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (!deathId.equals(old.deathId()) || age.isNegative() || age.compareTo(Duration.ofHours(24)) >= 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
var health = nativeLimit == null ? old.maximumHealth() : old.maximumHealth().min(nativeLimit);
|
||||
var restored = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints(), old.maximumHealth(), old.learned(),
|
||||
old.loadout(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
old.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(restored, old.revision());
|
||||
return Optional.of(restored);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
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.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
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;
|
||||
|
||||
/** Feature-scoped Revive interruption proof; never authority to guess or mint an item. */
|
||||
public final class RevivalStore implements AutoCloseable {
|
||||
private static final int MAX_FILE_BYTES = 32_768;
|
||||
private static final int MAX_FILES = 4096;
|
||||
private static final Set<String> KEYS = Set.of("schema-version", "reservation-id", "caster-character-id",
|
||||
"target-player-id", "character-id", "death-id", "created-at", "source-slot", "source-amount", "destination-slot");
|
||||
public record Reservation(UUID id, UUID casterCharacter, UUID targetPlayer, UUID character, UUID death,
|
||||
Instant createdAt, int sourceSlot, int sourceAmount, int destinationSlot) {
|
||||
public Reservation {
|
||||
Objects.requireNonNull(id); Objects.requireNonNull(casterCharacter); Objects.requireNonNull(targetPlayer);
|
||||
Objects.requireNonNull(character); Objects.requireNonNull(death); Objects.requireNonNull(createdAt);
|
||||
if (createdAt.getNano() != 0 || sourceSlot < 0 || sourceSlot > 40 || sourceSlot > 35 && sourceSlot != 40
|
||||
|| sourceAmount < 1 || sourceAmount > 99 || destinationSlot < -1 || destinationSlot > 40
|
||||
|| destinationSlot > 35 && destinationSlot != 40 || destinationSlot == sourceSlot) {
|
||||
throw new IllegalArgumentException("Invalid revival reservation");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-revival-persistence"));
|
||||
|
||||
public RevivalStore(Path pluginDataDirectory) {
|
||||
directory = Objects.requireNonNull(pluginDataDirectory).resolve("revivals").toAbsolutePath();
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<Reservation>> reserve(Reservation reservation) {
|
||||
Objects.requireNonNull(reservation);
|
||||
return submit(() -> {
|
||||
Path file = file(reservation.character());
|
||||
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
|
||||
safeDirectory();
|
||||
Path temporary = directory.resolve("." + reservation.character() + "." + UUID.randomUUID() + ".tmp");
|
||||
try {
|
||||
Files.writeString(temporary, encode(reservation), StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
|
||||
Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
return Optional.of(reservation);
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<Reservation>> find(UUID character) {
|
||||
Objects.requireNonNull(character);
|
||||
return submit(() -> load(file(character)));
|
||||
}
|
||||
|
||||
public CompletableFuture<List<Reservation>> target(UUID player) {
|
||||
Objects.requireNonNull(player);
|
||||
return submit(() -> {
|
||||
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) return List.of();
|
||||
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Unsafe revival directory");
|
||||
}
|
||||
var result = new ArrayList<Reservation>();
|
||||
try (var files = Files.list(directory)) {
|
||||
for (var path : files.limit(MAX_FILES + 1).toList()) {
|
||||
if (result.size() >= MAX_FILES) throw new IOException("Too many revival reservations");
|
||||
if (!path.getFileName().toString().endsWith(".yml")) continue;
|
||||
var reservation = load(path).orElseThrow(() -> new IOException("Missing revival reservation"));
|
||||
if (reservation.targetPlayer().equals(player)) result.add(reservation);
|
||||
}
|
||||
}
|
||||
return List.copyOf(result);
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Boolean> clear(UUID character, UUID reservation) {
|
||||
Objects.requireNonNull(character); Objects.requireNonNull(reservation);
|
||||
return submit(() -> {
|
||||
var found = load(file(character));
|
||||
if (found.isEmpty() || !found.orElseThrow().id().equals(reservation)) return false;
|
||||
return Files.deleteIfExists(file(character));
|
||||
});
|
||||
}
|
||||
|
||||
private void safeDirectory() throws IOException {
|
||||
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Unsafe revival directory");
|
||||
}
|
||||
} else Files.createDirectories(directory);
|
||||
}
|
||||
|
||||
private Path file(UUID character) { return directory.resolve(character + ".yml"); }
|
||||
|
||||
private Optional<Reservation> load(Path file) throws IOException {
|
||||
if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
|
||||
if (Files.isSymbolicLink(file) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)
|
||||
|| Files.size(file) > MAX_FILE_BYTES) throw new IOException("Unsafe revival reservation");
|
||||
Object decoded;
|
||||
try {
|
||||
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false); options.setMaxAliasesForCollections(0);
|
||||
decoded = new Yaml(new SafeConstructor(options)).load(Files.readString(file, StandardCharsets.UTF_8));
|
||||
} catch (RuntimeException invalid) { throw new IOException("Invalid revival reservation", invalid); }
|
||||
if (!(decoded instanceof Map<?, ?> raw) || !raw.keySet().equals(KEYS)) throw new IOException("Invalid revival reservation keys");
|
||||
try {
|
||||
if (!"1".equals(text(raw, "schema-version"))) throw new IllegalArgumentException("schema");
|
||||
var value = new Reservation(uuid(raw, "reservation-id"), uuid(raw, "caster-character-id"),
|
||||
uuid(raw, "target-player-id"), uuid(raw, "character-id"), uuid(raw, "death-id"),
|
||||
Instant.parse(text(raw, "created-at")), integer(raw, "source-slot"), integer(raw, "source-amount"),
|
||||
integer(raw, "destination-slot"));
|
||||
if (!file.getFileName().toString().equals(value.character() + ".yml")) throw new IllegalArgumentException("identity");
|
||||
return Optional.of(value);
|
||||
} catch (RuntimeException invalid) { throw new IOException("Invalid revival reservation", invalid); }
|
||||
}
|
||||
|
||||
private static String encode(Reservation value) {
|
||||
var map = new LinkedHashMap<String, Object>();
|
||||
map.put("schema-version", "1"); map.put("reservation-id", value.id().toString());
|
||||
map.put("caster-character-id", value.casterCharacter().toString()); map.put("target-player-id", value.targetPlayer().toString());
|
||||
map.put("character-id", value.character().toString()); map.put("death-id", value.death().toString());
|
||||
map.put("created-at", value.createdAt().toString()); map.put("source-slot", Integer.toString(value.sourceSlot()));
|
||||
map.put("source-amount", Integer.toString(value.sourceAmount())); map.put("destination-slot", Integer.toString(value.destinationSlot()));
|
||||
return new Yaml().dump(map);
|
||||
}
|
||||
|
||||
private static String text(Map<?, ?> map, String key) {
|
||||
Object value = map.get(key); if (!(value instanceof String text) || text.isBlank()) throw new IllegalArgumentException(key); return text;
|
||||
}
|
||||
private static UUID uuid(Map<?, ?> map, String key) { var value = UUID.fromString(text(map, key)); if (!value.toString().equals(text(map, key))) throw new IllegalArgumentException(key); return value; }
|
||||
private static int integer(Map<?, ?> map, String key) { return Integer.parseInt(text(map, key)); }
|
||||
|
||||
@FunctionalInterface private interface Work<T> { T run() throws IOException; }
|
||||
private <T> CompletableFuture<T> submit(Work<T> work) {
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> { try { return work.run(); } catch (IOException error) { throw new CompletionException(error); } }, worker);
|
||||
} catch (RejectedExecutionException rejected) { return CompletableFuture.failedFuture(rejected); }
|
||||
}
|
||||
@Override public void close() { worker.shutdown(); }
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
# Admin command: /fantasystick config adventure-mode [true|false]
|
||||
adventure-mode: true
|
||||
|
||||
# Real-time delay before this player may enter a different character after exit.
|
||||
character-swap-cooldown-seconds: 900
|
||||
|
||||
# New-character and transferable fantasy progression defaults.
|
||||
starting-points: 40
|
||||
xp-per-level: 100
|
||||
|
||||
@@ -1102,6 +1102,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
final List<BukkitTask> timers = new ArrayList<>();
|
||||
final List<Runnable> pulses = new ArrayList<>();
|
||||
final List<Runnable> delayed = new ArrayList<>();
|
||||
final List<Long> delayedTicks = new ArrayList<>();
|
||||
com.comphenix.protocol.events.PacketListener packetListener;
|
||||
final List<java.util.concurrent.ExecutorService> workers = new ArrayList<>();
|
||||
final Thread main = Thread.currentThread();
|
||||
@@ -1173,7 +1174,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
case "runTaskLater" -> {
|
||||
schedulerCalls.incrementAndGet();
|
||||
assertTrue(((org.bukkit.plugin.Plugin) args[0]).isEnabled());
|
||||
delayed.add((Runnable) args[1]);
|
||||
delayed.add((Runnable) args[1]); delayedTicks.add((Long) args[2]);
|
||||
var task = mock(BukkitTask.class); timers.add(task); yield task;
|
||||
}
|
||||
default -> throw new AssertionError("Unexpected scheduler call " + name);
|
||||
@@ -1187,6 +1188,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
case "getOnlinePlayers" -> List.copyOf(players.values());
|
||||
case "getPlayer" -> { assertSame(main, Thread.currentThread()); playerLookups++; yield players.get(args[0]); }
|
||||
case "getPluginCommand" -> command;
|
||||
case "createInventory" -> new org.bukkit.craftbukkit.inventory.CraftInventoryCustom(
|
||||
(org.bukkit.inventory.InventoryHolder) args[0], (Integer) args[1], (net.kyori.adventure.text.Component) args[2]);
|
||||
case "addRecipe" -> {
|
||||
order.add("recipe");
|
||||
if ("recipe".equals(failRegistrationAt)) throw new IllegalStateException("Recipe registration failed");
|
||||
@@ -1262,7 +1265,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(actor.core.marker().endsWith("|PACKED"));
|
||||
}
|
||||
private void captureWorkers(JavaPlugin instance) {
|
||||
for (String name : List.of("characters", "receipts", "adventureSettings")) {
|
||||
for (String name : List.of("characters", "receipts", "adventureSettings", "revivals")) {
|
||||
var store = field(instance, name);
|
||||
if (store != null) {
|
||||
var worker = (java.util.concurrent.ExecutorService) field(store, "worker");
|
||||
@@ -1297,6 +1300,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
final org.bukkit.World world = mock(org.bukkit.World.class);
|
||||
final List<String> messages = new ArrayList<>();
|
||||
final List<org.bukkit.inventory.ItemStack> books = new ArrayList<>();
|
||||
final List<org.bukkit.inventory.Inventory> inventories = new ArrayList<>();
|
||||
int inventoryCloses;
|
||||
final List<Integer> particles = new ArrayList<>();
|
||||
final List<org.bukkit.Sound> sounds = new ArrayList<>();
|
||||
final net.minecraft.world.entity.ai.attributes.AttributeMap healthAttributes = new net.minecraft.world.entity.ai.attributes.AttributeMap(
|
||||
@@ -1361,6 +1366,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
case "getScoreboard" -> craftBoard;
|
||||
case "sendMessage" -> { messages.add((String) args[0]); yield null; }
|
||||
case "openBook" -> { books.add(((org.bukkit.inventory.ItemStack) args[0]).clone()); yield null; }
|
||||
case "openInventory" -> { inventories.add((org.bukkit.inventory.Inventory) args[0]); yield core.view; }
|
||||
case "closeInventory" -> { inventoryCloses++; yield null; }
|
||||
case "spawnParticle" -> { particles.add((Integer) args[2]); yield null; }
|
||||
case "playSound" -> { if (args.length > 1 && args[1] instanceof org.bukkit.Sound sound) sounds.add(sound); yield null; }
|
||||
default -> Proxy.getInvocationHandler(core.player).invoke(core.player, method, args);
|
||||
|
||||
@@ -101,6 +101,79 @@ class FireballNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void bookUnlearnRefundsOnceClearsOwnedFireballAndRetainsDeadlineThroughReuse() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withInt(data, a, 12);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Learn]"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[3]"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
org.mockito.Mockito.when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var projectile = projectile(a, new org.bukkit.Location(a.world, 1, 65.6, 0));
|
||||
var launches = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var eye = new org.bukkit.Location(a.world, 0, 65.6, 0); eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
|
||||
org.mockito.Mockito.doReturn(eye).when(a.player).getEyeLocation();
|
||||
org.mockito.Mockito.doAnswer(call -> { launches.incrementAndGet(); return projectile; }).when(a.player)
|
||||
.launchProjectile(org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Snowball.class),
|
||||
org.mockito.ArgumentMatchers.any(org.bukkit.util.Vector.class));
|
||||
cast(f, a);
|
||||
var cast = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
var deadline = cast.cooldowns().get("fireball"); assertNotNull(deadline); assertEquals(1, launches.get());
|
||||
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
for (var page : content.pages()) for (String line : page.raw().getString().split("\\n", -1)) {
|
||||
assertTrue(line.codePoints().map(cp -> cp < 128 ? 6 : 9).sum() <= 108,
|
||||
"Learned ability action exceeds native visible width: " + line);
|
||||
}
|
||||
var unlearn = PartyBookNativeTest.button(a, "[Unlearn]");
|
||||
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
|
||||
assertFalse(refunded.learned().contains("fireball"));
|
||||
assertTrue(refunded.loadout().stream().noneMatch("fireball"::equals));
|
||||
assertEquals(deadline, refunded.cooldowns().get("fireball"));
|
||||
org.mockito.Mockito.verify(projectile, org.mockito.Mockito.atLeastOnce()).remove();
|
||||
assertTrue(new games.dmg.fantasystick.items.SessionControls(
|
||||
(games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry"))
|
||||
.ability(a.core.inventory.getItem(2)).isEmpty());
|
||||
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[+1 WIS]"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var spent = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.ONE, spent.stats().get(games.dmg.fantasystick.progression.Stat.WIS));
|
||||
assertEquals(BigInteger.valueOf(39), spent.unspentPoints());
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Learn]"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[3]"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("fireball"));
|
||||
cast(f, a); assertEquals(1, launches.get());
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
|
||||
var transferredSheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(transferredSheet);
|
||||
f.players.put(b.core.id, b.player); f.activate(b);
|
||||
b.core.inventory.getInventory().setSelectedSlot(2);
|
||||
assertEquals("fireball", new games.dmg.fantasystick.items.SessionControls(
|
||||
(games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry"))
|
||||
.ability(b.core.inventory.getItem(2)).orElseThrow());
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(b.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
b.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
PartyBookNativeTest.drain(f);
|
||||
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")),
|
||||
"Transfer/reload must retain the original Fireball deadline after refund and relearn");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void partyAndMixedModeProjectileHitsPreserveProtectionCancellation() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "fireball-cooldown-seconds: 0\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ReviveNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void completedProgressionBudgetCanLearnAndAssignReviveWithoutALevelGate() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
try (var storage = new games.dmg.fantasystick.persistence.CharacterStorage(repository, java.time.Clock.systemUTC())) {
|
||||
var current = storage.awardExperience(actor.core.record.id(), actor.core.record.revision(),
|
||||
BigInteger.valueOf(2700), games.dmg.fantasystick.progression.ProgressionRules.defaults()).join().record().orElseThrow();
|
||||
for (var purchase : java.util.List.of(
|
||||
java.util.Map.entry(games.dmg.fantasystick.progression.Stat.WIS, 30),
|
||||
java.util.Map.entry(games.dmg.fantasystick.progression.Stat.CHR, 24),
|
||||
java.util.Map.entry(games.dmg.fantasystick.progression.Stat.CON, 12))) {
|
||||
for (int count = 0; count < purchase.getValue(); count++) {
|
||||
current = storage.purchaseStat(current.id(), current.revision(), purchase.getKey(), current.health(),
|
||||
new java.math.BigDecimal("1024")).join().record().orElseThrow();
|
||||
}
|
||||
}
|
||||
assertEquals(28, current.level()); assertEquals(BigInteger.ONE, current.unspentPoints());
|
||||
}
|
||||
fixture.plugin.setEnabled(true); fixture.players.put(actor.core.id, actor.player); fixture.activate(actor);
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
String text = PartyBookNativeTest.text(actor).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("Revive")); assertTrue(text.contains("WIS30")); assertTrue(text.contains("CHR24"));
|
||||
assertTrue(text.contains("CON12")); assertTrue(text.contains("Cooldown:86400s"));
|
||||
PartyBookNativeTest.click(fixture, actor, PartyBookNativeTest.button(actor, "[Learn]"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
PartyBookNativeTest.click(fixture, actor, PartyBookNativeTest.button(actor, "[3]"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
var saved = storage.find(actor.core.record.id()).get().orElseThrow();
|
||||
assertTrue(saved.learned().contains("revive")); assertEquals("revive", saved.loadout().getFirst());
|
||||
assertEquals(BigInteger.ZERO, saved.unspentPoints());
|
||||
assertEquals(org.bukkit.Material.ECHO_SHARD, actor.core.inventory.getItem(2).getType());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void ordinaryTargetSelectsOneTailAndReceivesTheSameLivingSheetOnce() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data); withRevive(data, caster);
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.players.put(target.core.id, target.player);
|
||||
fixture.activate(caster);
|
||||
assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions")).active(target.core.id).isEmpty());
|
||||
|
||||
var original = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var death = java.util.UUID.randomUUID();
|
||||
var died = java.time.Instant.now().minusSeconds(3600).truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
|
||||
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(original.id(), "Fallen Mira", original.revision(),
|
||||
original.stats(), 7, BigInteger.valueOf(642), BigInteger.valueOf(11), java.math.BigDecimal.ZERO,
|
||||
java.util.Set.of("fireball"), java.util.List.of("fireball", "", "", "", "", "", ""),
|
||||
java.util.Map.of("fireball", java.time.Instant.now().plusSeconds(30)), original.restrictedKills(), true, death, died);
|
||||
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).create(dead);
|
||||
var tail = new games.dmg.fantasystick.items.TailBooks().create(dead);
|
||||
var copiedStack = tail.clone(); copiedStack.setAmount(2); target.core.inventory.setItem(10, copiedStack);
|
||||
|
||||
caster.core.location.setWorld(caster.world); caster.core.location.setX(0); caster.core.location.setY(64); caster.core.location.setZ(0);
|
||||
target.core.location.setWorld(caster.world); target.core.location.setX(3); target.core.location.setY(64); target.core.location.setZ(0);
|
||||
var eye = new org.bukkit.Location(caster.world, 0, 65.6, 0); eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
|
||||
org.mockito.Mockito.doReturn(eye).when(caster.player).getEyeLocation();
|
||||
org.mockito.Mockito.doReturn(true).when(caster.player).hasLineOfSight(target.player);
|
||||
caster.core.inventory.getInventory().setSelectedSlot(2);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player, target.player,
|
||||
org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
assertEquals(1, target.inventories.size());
|
||||
var popup = target.inventories.getFirst();
|
||||
assertEquals(1, java.util.stream.IntStream.range(0, popup.getSize()).filter(slot -> {
|
||||
var item = popup.getItem(slot); return item != null && item.getType() == org.bukkit.Material.WRITTEN_BOOK;
|
||||
}).count());
|
||||
var view = org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);
|
||||
org.mockito.Mockito.when(view.getTopInventory()).thenReturn(popup);
|
||||
org.mockito.Mockito.when(view.getBottomInventory()).thenReturn(target.core.inventory);
|
||||
org.mockito.Mockito.when(view.getPlayer()).thenReturn(target.player);
|
||||
var selection = new org.bukkit.event.inventory.InventoryClickEvent(view,
|
||||
org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER, 0, org.bukkit.event.inventory.ClickType.LEFT,
|
||||
org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);
|
||||
fixture.dispatcher.accept(selection); assertTrue(selection.isCancelled(), "Popup copies cannot be exported");
|
||||
var revivalStore = (games.dmg.fantasystick.persistence.RevivalStore)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "revivals");
|
||||
PartyBookNativeTest.drain(fixture);
|
||||
assertTrue(revivalStore.find(dead.id()).join().isPresent());
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
var restored = storage.find(dead.id()).get().orElseThrow();
|
||||
assertFalse(restored.dead(), "caster=" + caster.messages + ", target=" + target.messages + ", source=" + target.core.inventory.getItem(10)
|
||||
+ ", reservation=" + revivalStore.find(dead.id()).join());
|
||||
assertEquals(dead.id(), restored.id()); assertEquals(dead.name(), restored.name());
|
||||
assertEquals(dead.stats(), restored.stats()); assertEquals(dead.level(), restored.level());
|
||||
assertEquals(dead.experience(), restored.experience()); assertEquals(dead.unspentPoints(), restored.unspentPoints());
|
||||
assertEquals(dead.learned(), restored.learned()); assertEquals(dead.loadout(), restored.loadout());
|
||||
assertEquals(dead.cooldowns(), restored.cooldowns()); assertEquals(restored.maximumHealth(), restored.health());
|
||||
assertEquals(dead.id(), new games.dmg.fantasystick.items.CharacterSheets()
|
||||
.identify(target.core.inventory.getItem(1)).orElseThrow());
|
||||
assertEquals(1, target.core.inventory.getItem(10).getAmount());
|
||||
assertEquals(new games.dmg.fantasystick.items.TailBooks.Identity(dead.id(), dead.deathId()),
|
||||
new games.dmg.fantasystick.items.TailBooks().identify(target.core.inventory.getItem(10)).orElseThrow());
|
||||
assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions")).active(target.core.id).isEmpty());
|
||||
fixture.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,
|
||||
org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER, 0, org.bukkit.event.inventory.ClickType.LEFT,
|
||||
org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));
|
||||
assertEquals(dead.id(), new games.dmg.fantasystick.items.CharacterSheets()
|
||||
.identify(target.core.inventory.getItem(1)).orElseThrow());
|
||||
assertTrue(storage.find(caster.core.record.id()).get().orElseThrow().cooldowns().get("revive")
|
||||
.isAfter(java.time.Instant.now().plusSeconds(86300)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void stackedTailWithNoSafeSheetDestinationChangesNothingAndKeepsTheCooldown() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data); withRevive(data, caster);
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.players.put(target.core.id, target.player); fixture.activate(caster);
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var death = java.util.UUID.randomUUID(); var died = java.time.Instant.now().minusSeconds(60).truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
|
||||
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), "Full inventory", base.revision(), base.stats(),
|
||||
base.level(), base.experience(), base.unspentPoints(), java.math.BigDecimal.ZERO, base.learned(), base.loadout(),
|
||||
base.cooldowns(), base.restrictedKills(), true, death, died);
|
||||
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).create(dead);
|
||||
for (int slot = 0; slot <= 40; slot++) if (slot <= 35 || slot == 40) {
|
||||
target.core.inventory.setItem(slot, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
|
||||
}
|
||||
var tail = new games.dmg.fantasystick.items.TailBooks().create(dead); tail.setAmount(2); target.core.inventory.setItem(10, tail);
|
||||
caster.core.location.setWorld(caster.world); caster.core.location.setY(64);
|
||||
target.core.location.setWorld(caster.world); target.core.location.setX(3); target.core.location.setY(64);
|
||||
org.mockito.Mockito.doReturn(new org.bukkit.Location(caster.world, 0, 65.6, 0)).when(caster.player).getEyeLocation();
|
||||
org.mockito.Mockito.doReturn(true).when(caster.player).hasLineOfSight(target.player);
|
||||
caster.core.inventory.getInventory().setSelectedSlot(2);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player, target.player,
|
||||
org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
var popup = target.inventories.getFirst(); var view = org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);
|
||||
org.mockito.Mockito.when(view.getTopInventory()).thenReturn(popup);
|
||||
org.mockito.Mockito.when(view.getBottomInventory()).thenReturn(target.core.inventory);
|
||||
org.mockito.Mockito.when(view.getPlayer()).thenReturn(target.player);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,
|
||||
org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER, 0, org.bukkit.event.inventory.ClickType.LEFT,
|
||||
org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));
|
||||
PartyBookNativeTest.drain(fixture);
|
||||
assertEquals(tail, target.core.inventory.getItem(10));
|
||||
assertTrue(((games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters")).find(dead.id()).get().orElseThrow().dead());
|
||||
assertTrue(((games.dmg.fantasystick.persistence.RevivalStore)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "revivals")).find(dead.id()).join().isEmpty());
|
||||
assertTrue(target.messages.stream().anyMatch(message -> message.contains("empty direct inventory slot")));
|
||||
assertNotNull(((games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters"))
|
||||
.find(caster.core.record.id()).get().orElseThrow().cooldowns().get("revive"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void confirmationRechecksTheExactTwentyFourHourBoundaryBeforeChangingTheTail() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data); withRevive(data, caster);
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.players.put(target.core.id, target.player); fixture.activate(caster);
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var death = java.util.UUID.randomUUID(); var recent = java.time.Instant.now().minusSeconds(60).truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
|
||||
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), "Boundary", base.revision(), base.stats(),
|
||||
base.level(), base.experience(), base.unspentPoints(), java.math.BigDecimal.ZERO, base.learned(), base.loadout(),
|
||||
base.cooldowns(), base.restrictedKills(), true, death, recent);
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data); repository.create(dead);
|
||||
var tail = new games.dmg.fantasystick.items.TailBooks().create(dead); target.core.inventory.setItem(10, tail);
|
||||
caster.core.location.setWorld(caster.world); caster.core.location.setY(64);
|
||||
target.core.location.setWorld(caster.world); target.core.location.setX(3); target.core.location.setY(64);
|
||||
var eye = new org.bukkit.Location(caster.world, 0, 65.6, 0);
|
||||
org.mockito.Mockito.doReturn(eye).when(caster.player).getEyeLocation();
|
||||
org.mockito.Mockito.doReturn(true).when(caster.player).hasLineOfSight(target.player);
|
||||
caster.core.inventory.getInventory().setSelectedSlot(2);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player, target.player,
|
||||
org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
var popup = target.inventories.getFirst();
|
||||
var expired = new games.dmg.fantasystick.persistence.CharacterRecord(dead.id(), dead.name(), dead.revision().add(BigInteger.ONE),
|
||||
dead.stats(), dead.level(), dead.experience(), dead.unspentPoints(), dead.health(), dead.learned(), dead.loadout(),
|
||||
dead.cooldowns(), dead.restrictedKills(), true, dead.deathId(),
|
||||
java.time.Instant.now().minusSeconds(86400).truncatedTo(java.time.temporal.ChronoUnit.SECONDS));
|
||||
repository.replace(expired, dead.revision());
|
||||
var view = org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);
|
||||
org.mockito.Mockito.when(view.getTopInventory()).thenReturn(popup);
|
||||
org.mockito.Mockito.when(view.getBottomInventory()).thenReturn(target.core.inventory);
|
||||
org.mockito.Mockito.when(view.getPlayer()).thenReturn(target.player);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,
|
||||
org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER, 0, org.bukkit.event.inventory.ClickType.LEFT,
|
||||
org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));
|
||||
PartyBookNativeTest.drain(fixture);
|
||||
assertEquals(tail, target.core.inventory.getItem(10));
|
||||
assertTrue(((games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters")).find(dead.id()).get().orElseThrow().dead());
|
||||
assertTrue(((games.dmg.fantasystick.persistence.RevivalStore)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "revivals")).find(dead.id()).join().isEmpty());
|
||||
assertTrue(caster.messages.stream().anyMatch(message -> message.contains("no longer eligible")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void eyeRayRequiresAValidTargetAndEmptyResultStillConsumesTheDailyCooldown() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data); withRevive(data, caster);
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.players.put(target.core.id, target.player);
|
||||
fixture.activate(caster);
|
||||
caster.core.location.setWorld(caster.world); caster.core.location.setY(64);
|
||||
target.core.location.setWorld(caster.world); target.core.location.setX(12); target.core.location.setY(64);
|
||||
var eye = new org.bukkit.Location(caster.world, 0, 65.6, 0); eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
|
||||
org.mockito.Mockito.doReturn(eye).when(caster.player).getEyeLocation();
|
||||
org.mockito.Mockito.doReturn(true).when(caster.player).hasLineOfSight(target.player);
|
||||
caster.core.inventory.getInventory().setSelectedSlot(2);
|
||||
var use = new org.bukkit.event.player.PlayerInteractEvent(caster.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
caster.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
fixture.dispatcher.accept(use);
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
assertFalse(storage.find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("revive"));
|
||||
|
||||
org.mockito.Mockito.doReturn(new org.bukkit.util.RayTraceResult(new org.bukkit.util.Vector(12, 65, 0), target.player))
|
||||
.when(caster.world).rayTraceEntities(org.mockito.ArgumentMatchers.any(org.bukkit.Location.class),
|
||||
org.mockito.ArgumentMatchers.any(org.bukkit.util.Vector.class), org.mockito.ArgumentMatchers.eq(12.0),
|
||||
org.mockito.ArgumentMatchers.<java.util.function.Predicate<org.bukkit.entity.Entity>>any());
|
||||
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(caster.player,
|
||||
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, caster.core.inventory.getItem(2), null,
|
||||
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
assertTrue(storage.find(caster.core.record.id()).get().orElseThrow().cooldowns().get("revive")
|
||||
.isAfter(java.time.Instant.now().plusSeconds(86300)));
|
||||
assertTrue(caster.messages.stream().anyMatch(message -> message.contains("no direct-inventory Tail")));
|
||||
assertTrue(target.inventories.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unlearningDuringFantasyTargetPopupCancelsSelectionWithoutResettingDailyDeadline() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data); withRevive(data, caster);
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.players.put(target.core.id, target.player);
|
||||
fixture.activate(caster); fixture.activate(target);
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var death = java.util.UUID.randomUUID(); var died = java.time.Instant.now().minusSeconds(60).truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
|
||||
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), "Pending", base.revision(), base.stats(),
|
||||
base.level(), base.experience(), base.unspentPoints(), java.math.BigDecimal.ZERO, base.learned(), base.loadout(),
|
||||
base.cooldowns(), base.restrictedKills(), true, death, died);
|
||||
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).create(dead);
|
||||
var tail = new games.dmg.fantasystick.items.TailBooks().create(dead); target.core.inventory.setItem(10, tail);
|
||||
caster.core.location.setWorld(caster.world); caster.core.location.setY(64);
|
||||
target.core.location.setWorld(caster.world); target.core.location.setX(3); target.core.location.setY(64);
|
||||
var eye = new org.bukkit.Location(caster.world, 0, 65.6, 0);
|
||||
org.mockito.Mockito.doReturn(eye).when(caster.player).getEyeLocation();
|
||||
org.mockito.Mockito.doReturn(true).when(caster.player).hasLineOfSight(target.player);
|
||||
caster.core.inventory.getInventory().setSelectedSlot(2);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player, target.player,
|
||||
org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
assertEquals(1, target.inventories.size()); var popup = target.inventories.getFirst();
|
||||
assertTrue(fixture.delayedTicks.contains(6000L), "The popup expires at exactly five real-time minutes");
|
||||
var view = org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);
|
||||
org.mockito.Mockito.when(view.getTopInventory()).thenReturn(popup);
|
||||
org.mockito.Mockito.when(view.getBottomInventory()).thenReturn(target.core.inventory);
|
||||
org.mockito.Mockito.when(view.getPlayer()).thenReturn(target.player);
|
||||
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
|
||||
PartyBookNativeTest.click(fixture, caster, PartyBookNativeTest.button(caster, "[Unlearn]"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
var refunded = storage.find(caster.core.record.id()).get().orElseThrow();
|
||||
var deadline = refunded.cooldowns().get("revive"); assertNotNull(deadline); assertFalse(refunded.learned().contains("revive"));
|
||||
assertTrue(java.util.stream.IntStream.range(0, popup.getSize()).allMatch(slot -> popup.getItem(slot) == null));
|
||||
fixture.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,
|
||||
org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER, 0, org.bukkit.event.inventory.ClickType.LEFT,
|
||||
org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));
|
||||
assertTrue(storage.find(dead.id()).get().orElseThrow().dead()); assertEquals(tail, target.core.inventory.getItem(10));
|
||||
|
||||
PartyBookNativeTest.click(fixture, caster, PartyBookNativeTest.button(caster, "[Learn]"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
PartyBookNativeTest.click(fixture, caster, PartyBookNativeTest.button(caster, "[3]"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
caster.core.inventory.getInventory().setSelectedSlot(2);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player, target.player,
|
||||
org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
PartyBookNativeTest.drain(fixture);
|
||||
assertEquals(1, target.inventories.size(), "Relearning cannot bypass the original daily deadline");
|
||||
assertEquals(deadline, storage.find(caster.core.record.id()).get().orElseThrow().cooldowns().get("revive"));
|
||||
|
||||
var transferred = caster.core.inventory.getItemInOffHand().clone();
|
||||
caster.core.inventory.setItemInOffHand(null); fixture.changed(caster);
|
||||
PartyBookNativeTest.drain(fixture); fixture.awaitCharacterAndReceipt(caster);
|
||||
var current = storage.find(caster.core.record.id()).get().orElseThrow();
|
||||
var renamed = storage.renameCharacter(current.id(), "Renamed daily caster").get().orElseThrow();
|
||||
var nextHolder = new FantasyStickLifecycleNativeTest.Actor(data); nextHolder.core.inventory.setItemInOffHand(transferred);
|
||||
fixture.players.put(nextHolder.core.id, nextHolder.player); fixture.activate(nextHolder);
|
||||
nextHolder.core.inventory.getInventory().setSelectedSlot(2);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(nextHolder.player, target.player,
|
||||
org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
PartyBookNativeTest.drain(fixture);
|
||||
assertTrue(nextHolder.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
assertEquals("Renamed daily caster", renamed.name()); assertEquals(deadline, renamed.cooldowns().get("revive"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void startupReconciliationCompletesOnlyAProvedDeliveredSheetAndClearsAnUnchangedTail() throws Exception {
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data); target.core.inventory.setItemInOffHand(null);
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var death = java.util.UUID.randomUUID(); var died = java.time.Instant.now().minusSeconds(60).truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
|
||||
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), "Interrupted", base.revision(), base.stats(),
|
||||
base.level(), base.experience(), base.unspentPoints(), java.math.BigDecimal.ZERO, base.learned(), base.loadout(),
|
||||
base.cooldowns(), base.restrictedKills(), true, death, died);
|
||||
repository.create(dead);
|
||||
var provisional = new games.dmg.fantasystick.persistence.CharacterRecord(dead.id(), dead.name(), dead.revision(), dead.stats(),
|
||||
dead.level(), dead.experience(), dead.unspentPoints(), dead.maximumHealth(), dead.learned(), dead.loadout(),
|
||||
dead.cooldowns(), dead.restrictedKills(), false, dead.deathId(), dead.diedAt());
|
||||
target.core.inventory.setItem(10, new games.dmg.fantasystick.items.CharacterSheets().create(provisional));
|
||||
var reservation = new games.dmg.fantasystick.persistence.RevivalStore.Reservation(java.util.UUID.randomUUID(),
|
||||
java.util.UUID.randomUUID(), target.core.id, dead.id(), death, java.time.Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS),
|
||||
10, 1, -1);
|
||||
try (var store = new games.dmg.fantasystick.persistence.RevivalStore(data)) { store.reserve(reservation).join(); }
|
||||
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.players.put(target.core.id, target.player); fixture.plugin.setEnabled(true);
|
||||
var store = (games.dmg.fantasystick.persistence.RevivalStore)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "revivals");
|
||||
store.target(target.core.id).join(); PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
assertFalse(storage.find(dead.id()).get().orElseThrow().dead());
|
||||
assertTrue(store.find(dead.id()).join().isEmpty());
|
||||
assertEquals(dead.id(), new games.dmg.fantasystick.items.CharacterSheets()
|
||||
.identify(target.core.inventory.getItem(10)).orElseThrow());
|
||||
|
||||
var secondBase = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var secondDeath = java.util.UUID.randomUUID();
|
||||
var second = new games.dmg.fantasystick.persistence.CharacterRecord(secondBase.id(), "Not delivered", secondBase.revision(),
|
||||
secondBase.stats(), secondBase.level(), secondBase.experience(), secondBase.unspentPoints(), java.math.BigDecimal.ZERO,
|
||||
secondBase.learned(), secondBase.loadout(), secondBase.cooldowns(), secondBase.restrictedKills(), true, secondDeath, died);
|
||||
repository.create(second); var tail = new games.dmg.fantasystick.items.TailBooks().create(second); target.core.inventory.setItem(12, tail);
|
||||
var untouched = new games.dmg.fantasystick.persistence.RevivalStore.Reservation(java.util.UUID.randomUUID(),
|
||||
java.util.UUID.randomUUID(), target.core.id, second.id(), secondDeath,
|
||||
java.time.Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS), 12, 1, -1);
|
||||
store.reserve(untouched).join();
|
||||
((games.dmg.fantasystick.ability.Revive) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "revive")).reconcile(target.player);
|
||||
store.target(target.core.id).join(); PartyBookNativeTest.drain(fixture);
|
||||
assertTrue(storage.find(second.id()).get().orElseThrow().dead());
|
||||
assertEquals(tail, target.core.inventory.getItem(12)); assertTrue(store.find(second.id()).join().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
private static void withRevive(Path data, FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
var stats = new java.util.EnumMap<>(old.stats());
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.WIS, BigInteger.valueOf(30));
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.CHR, BigInteger.valueOf(24));
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.CON, BigInteger.valueOf(12));
|
||||
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE),
|
||||
stats, old.level(), old.experience(), old.unspentPoints(), old.maximumHealth(), java.util.Set.of("revive"),
|
||||
java.util.List.of("revive", "", "", "", "", "", ""), old.cooldowns(), old.restrictedKills(), false, null, null);
|
||||
repository.replace(saved, old.revision());
|
||||
}
|
||||
|
||||
@Test void packagedDefinitionMatchesReviewedReviveContract() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
|
||||
var revive = registry.find("revive").orElseThrow();
|
||||
assertEquals(org.bukkit.Material.ECHO_SHARD, revive.icon());
|
||||
assertEquals(BigInteger.ONE, revive.pointCost());
|
||||
assertEquals(BigInteger.valueOf(30), revive.requirements().get(games.dmg.fantasystick.progression.Stat.WIS));
|
||||
assertEquals(BigInteger.valueOf(24), revive.requirements().get(games.dmg.fantasystick.progression.Stat.CHR));
|
||||
assertEquals(BigInteger.valueOf(12), revive.requirements().get(games.dmg.fantasystick.progression.Stat.CON));
|
||||
assertEquals(Duration.ofHours(24), revive.cooldown());
|
||||
assertEquals(games.dmg.fantasystick.session.ControlInput.Gesture.USE, revive.gesture());
|
||||
assertEquals(games.dmg.fantasystick.ability.AbilityDefinition.Activation.SELECTION, revive.activation());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,15 @@ class DistributionTest {
|
||||
assertEquals(1, metadata.getPermissions().size());
|
||||
assertEquals("fantasystick.admin", metadata.getPermissions().getFirst().getName());
|
||||
assertEquals(org.bukkit.permissions.PermissionDefault.OP, metadata.getPermissions().getFirst().getDefault());
|
||||
var configuration = jar.getJarEntry("config.yml");
|
||||
assertNotNull(configuration, "Ship reviewed session defaults for administrators");
|
||||
String defaults;
|
||||
try (var input = jar.getInputStream(configuration)) {
|
||||
defaults = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
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(JavaPlugin.class.isAssignableFrom(FantasyStickPlugin.class));
|
||||
var entrypoint = jar.getJarEntry(metadata.getMain().replace('.', '/') + ".class");
|
||||
assertNotNull(entrypoint);
|
||||
|
||||
@@ -169,6 +169,32 @@ class CharacterStorageTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void abilityUnlearnRefundsExactlyOnePointClearsEverySlotAndPreservesCooldown() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var deadline = java.time.Instant.parse("2026-09-21T12:00:00Z");
|
||||
var learned = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
|
||||
base.experience(), BigInteger.valueOf(39), base.health(), java.util.Set.of("fireball"),
|
||||
java.util.List.of("fireball", "", "fireball", "", "", "", ""), java.util.Map.of("fireball", deadline),
|
||||
base.restrictedKills(), false, null, null);
|
||||
repository.create(learned);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var refunded = storage.unlearnAbility(learned.id(), learned.revision(), "fireball").join();
|
||||
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, refunded.status());
|
||||
var saved = refunded.record().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(40), saved.unspentPoints());
|
||||
assertFalse(saved.learned().contains("fireball"));
|
||||
assertTrue(saved.loadout().stream().noneMatch("fireball"::equals));
|
||||
assertEquals(deadline, saved.cooldowns().get("fireball"));
|
||||
assertEquals(saved, repository.load(saved.id()).orElseThrow());
|
||||
assertEquals(CharacterStorage.AbilityMutationStatus.NOT_LEARNED,
|
||||
storage.unlearnAbility(saved.id(), saved.revision(), "fireball").join().status());
|
||||
assertEquals(saved, repository.load(saved.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void abilityUnlockRefusesMissingRequirementsAndPointsWithoutChangingSavedState() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
@@ -227,6 +253,32 @@ class CharacterStorageTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedAbilityRefundLeavesLearnedStateAndPointsUnchangedForOneRetry() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var learned = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
|
||||
base.experience(), BigInteger.valueOf(39), base.health(), java.util.Set.of("fireball"),
|
||||
java.util.List.of("fireball", "", "", "", "", "", ""),
|
||||
java.util.Map.of("fireball", java.time.Instant.parse("2026-09-22T00:00:00Z")),
|
||||
base.restrictedKills(), false, null, null);
|
||||
repository.create(learned);
|
||||
var failing = new YamlCharacterRepository(data, (source, target) -> { throw new IOException("simulated refund failure"); });
|
||||
try (var storage = new CharacterStorage(failing, Clock.systemUTC())) {
|
||||
assertThrows(CompletionException.class,
|
||||
() -> storage.unlearnAbility(learned.id(), learned.revision(), "fireball").join());
|
||||
}
|
||||
assertEquals(learned, repository.load(learned.id()).orElseThrow());
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var refund = storage.unlearnAbility(learned.id(), learned.revision(), "fireball").join();
|
||||
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, refund.status());
|
||||
assertEquals(BigInteger.valueOf(40), refund.record().orElseThrow().unspentPoints());
|
||||
assertEquals(CharacterStorage.StatPurchaseStatus.STALE,
|
||||
storage.purchaseStat(learned.id(), learned.revision(), games.dmg.fantasystick.progression.Stat.STR,
|
||||
learned.health(), new java.math.BigDecimal("1024")).join().status());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedAbilityUnlockSaveLeavesPointsAndLearnedStateUnchangedForRetry() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
@@ -246,6 +298,24 @@ class CharacterStorageTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativeReviveBoundsFullHealthWithoutCappingTransferableCon() throws Exception {
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var stats = new java.util.EnumMap<>(base.stats());
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.CON, BigInteger.valueOf(5000));
|
||||
var death = UUID.randomUUID(); var died = java.time.Instant.parse("2026-09-20T08:00:00Z");
|
||||
var dead = new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
|
||||
base.unspentPoints(), java.math.BigDecimal.ZERO, base.learned(), base.loadout(), base.cooldowns(),
|
||||
base.restrictedKills(), true, death, died);
|
||||
var repository = new YamlCharacterRepository(data); repository.create(dead);
|
||||
try (var storage = new CharacterStorage(repository,
|
||||
Clock.fixed(java.time.Instant.parse("2026-09-20T09:00:00Z"), ZoneOffset.UTC))) {
|
||||
var restored = storage.restoreDeathRecord(dead.id(), death, new java.math.BigDecimal("1024")).join().orElseThrow();
|
||||
assertEquals(new java.math.BigDecimal("1024"), restored.health());
|
||||
assertEquals(BigInteger.valueOf(5000), restored.stats().get(games.dmg.fantasystick.progression.Stat.CON));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedRevivalSaveLeavesDeadStateRetryableAndShutdownRejectsNewWork() throws Exception {
|
||||
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class RevivalStoreTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void oneReservationRoundTripsAcrossReopenAndClearsOnlyWithItsToken() throws Exception {
|
||||
var value = reservation();
|
||||
try (var store = new RevivalStore(data)) {
|
||||
assertEquals(value, store.reserve(value).join().orElseThrow());
|
||||
assertTrue(store.reserve(new RevivalStore.Reservation(UUID.randomUUID(), value.casterCharacter(),
|
||||
value.targetPlayer(), value.character(), value.death(), value.createdAt(), value.sourceSlot(),
|
||||
value.sourceAmount(), value.destinationSlot())).join().isEmpty());
|
||||
assertEquals(value, store.find(value.character()).join().orElseThrow());
|
||||
assertEquals(java.util.List.of(value), store.target(value.targetPlayer()).join());
|
||||
assertFalse(store.clear(value.character(), UUID.randomUUID()).join());
|
||||
}
|
||||
try (var reopened = new RevivalStore(data)) {
|
||||
assertEquals(value, reopened.find(value.character()).join().orElseThrow());
|
||||
assertTrue(reopened.clear(value.character(), value.id()).join());
|
||||
assertTrue(reopened.find(value.character()).join().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void malformedOversizedAndSymlinkedReservationStateIsRetainedAndRejected() throws Exception {
|
||||
var value = reservation();
|
||||
try (var store = new RevivalStore(data)) { store.reserve(value).join(); }
|
||||
Path file = data.resolve("revivals").resolve(value.character() + ".yml");
|
||||
String valid = Files.readString(file);
|
||||
Files.writeString(file, valid + "unknown: value\n");
|
||||
try (var store = new RevivalStore(data)) {
|
||||
assertThrows(CompletionException.class, () -> store.find(value.character()).join());
|
||||
}
|
||||
assertTrue(Files.readString(file).endsWith("unknown: value\n"));
|
||||
Files.delete(file);
|
||||
Path outside = data.resolve("outside.yml"); Files.writeString(outside, valid);
|
||||
try {
|
||||
Files.createSymbolicLink(file, outside);
|
||||
try (var store = new RevivalStore(data)) {
|
||||
assertThrows(CompletionException.class, () -> store.find(value.character()).join());
|
||||
}
|
||||
} catch (UnsupportedOperationException ignored) { }
|
||||
}
|
||||
|
||||
private static RevivalStore.Reservation reservation() {
|
||||
return new RevivalStore.Reservation(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(),
|
||||
UUID.randomUUID(), Instant.parse("2026-09-20T10:00:00Z"), 10, 2, 12);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user