Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
636ff9f076 | ||
|
|
7158817726 | ||
|
|
8ee0d98599 | ||
|
|
539900eb14 |
No files matched your search
@@ -2,6 +2,8 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore: [main]
|
||||
tags-ignore: ['**']
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -21,7 +21,7 @@ import java.time.Clock;
|
||||
import java.util.logging.Level;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
/** Crafting, native fantasy sessions, books, parties and names; progression actions and spells remain separate. */
|
||||
/** Crafting, native fantasy sessions, books, parties, names and permanent-stat progression. */
|
||||
public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private CharacterStorage characters;
|
||||
private ManualCraftingController crafting;
|
||||
@@ -44,6 +44,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.session.AdventureModeLease gameModes;
|
||||
private games.dmg.fantasystick.session.SessionVitals vitals;
|
||||
private games.dmg.fantasystick.session.SessionVisuals sessionVisuals;
|
||||
private games.dmg.fantasystick.combat.BasicAttack basicAttack;
|
||||
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
|
||||
|
||||
@Override public void onEnable() {
|
||||
@@ -67,9 +68,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var adventure = games.dmg.fantasystick.persistence.AdventureSettings.validate(getConfig().get("adventure-mode"));
|
||||
adventureSettings = new games.dmg.fantasystick.persistence.AdventureSettings(getDataFolder().toPath(), adventure);
|
||||
var switchDelay = switchDelay();
|
||||
var defaults = ProgressionRules.defaults();
|
||||
var rules = new ProgressionRules(new java.math.BigInteger(getConfig().getString("starting-points", "40")),
|
||||
defaults.xpPerLevel(), defaults.maximumLevel());
|
||||
var rules = progressionRules();
|
||||
var factory = new CharacterFactory();
|
||||
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC());
|
||||
crafting = new ManualCraftingController(characters, () -> factory.create(rules),
|
||||
@@ -116,14 +115,19 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
});
|
||||
partyEvents = new games.dmg.fantasystick.party.PartyEvents(parties);
|
||||
partyCombat = new games.dmg.fantasystick.party.PartyCombat(parties, sessions);
|
||||
books = new games.dmg.fantasystick.book.CharacterBookController(characters, rules, nextTick,
|
||||
books = new games.dmg.fantasystick.book.CharacterBookController(characters, rules, sessions, nextTick,
|
||||
problem -> getLogger().log(Level.WARNING, "Character book failed for player " + problem.player()
|
||||
+ "; character " + problem.character(), problem.cause()), parties, getServer(), id -> {
|
||||
String name = localPresentation.get().name(id);
|
||||
var player = getServer().getPlayer(id);
|
||||
return net.kyori.adventure.text.Component.text(name != null ? name : player == null ? id.toString() : player.getName());
|
||||
});
|
||||
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics, books);
|
||||
var localBasicAttack = new games.dmg.fantasystick.combat.BasicAttack(sessions);
|
||||
basicAttack = localBasicAttack;
|
||||
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics, input -> {
|
||||
books.accept(input);
|
||||
localBasicAttack.accept(input);
|
||||
});
|
||||
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
|
||||
problem -> getLogger().log(Level.WARNING, "Equipment admin " + problem.operation()
|
||||
+ " for player " + problem.player(), problem.cause()), adventureSettings);
|
||||
@@ -138,6 +142,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(partyEvents, this);
|
||||
getServer().getPluginManager().registerEvents(partyCombat, this);
|
||||
partyCombat.install(this);
|
||||
getServer().getPluginManager().registerEvents(localBasicAttack, this);
|
||||
getServer().getPluginManager().registerEvents(books, this);
|
||||
getServer().getPluginManager().registerEvents(gameModes, this);
|
||||
sessionVisuals = new games.dmg.fantasystick.session.SessionVisuals(sessions,
|
||||
@@ -175,6 +180,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingModes = gameModes; gameModes = null;
|
||||
var closingVitals = vitals; vitals = null;
|
||||
var closingVisuals = sessionVisuals; sessionVisuals = null;
|
||||
var closingBasicAttack = basicAttack; basicAttack = null;
|
||||
// Retire client resources independently of successful ordinary-equipment restoration.
|
||||
if (closingPresentation != null) cleanup("presentation", closingPresentation::close);
|
||||
if (closingProtocol != null) cleanup("protocol presentation", closingProtocol::close);
|
||||
@@ -184,6 +190,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
cleanup("configuration", closingSettings::close);
|
||||
}
|
||||
if (closingBooks != null) cleanup("character book", closingBooks::close);
|
||||
if (closingBasicAttack != null) cleanup("basic attack", closingBasicAttack::close);
|
||||
if (closingVisuals != null) cleanup("session particles", closingVisuals::close);
|
||||
if (closingParties != null) cleanup("parties", closingParties::close);
|
||||
if (closingPartyCombat != null) cleanup("party combat", closingPartyCombat::close);
|
||||
@@ -195,6 +202,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
unregister(closingChat);
|
||||
unregister(closingPartyEvents);
|
||||
unregister(closingPartyCombat);
|
||||
unregister(closingBasicAttack);
|
||||
unregister(closingBooks);
|
||||
unregister(closingEvents);
|
||||
unregister(closingCrafting);
|
||||
@@ -225,6 +233,17 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
private ProgressionRules progressionRules() {
|
||||
try {
|
||||
var starting = new java.math.BigInteger(getConfig().getString("starting-points", "40"));
|
||||
var threshold = new java.math.BigInteger(getConfig().getString("xp-per-level", "100"));
|
||||
var maximum = new java.math.BigInteger(getConfig().getString("maximum-level", "30")).intValueExact();
|
||||
return new ProgressionRules(starting, threshold, maximum);
|
||||
} catch (ArithmeticException invalid) {
|
||||
throw new IllegalArgumentException("maximum-level exceeds the supported integer range", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private java.time.Duration switchDelay() {
|
||||
Object configured = getConfig().get("character-swap-cooldown-seconds", 900);
|
||||
String value = String.valueOf(configured);
|
||||
|
||||
@@ -16,11 +16,12 @@ import org.bukkit.Material;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.BookMeta;
|
||||
|
||||
/** Authoritative inspection and validated party controls; character mutation actions remain unfinished. */
|
||||
/** Authoritative inspection, permanent-stat purchases and validated party controls. */
|
||||
public final class CharacterBookController implements Consumer<ControlInput>, AutoCloseable, org.bukkit.event.Listener {
|
||||
public record Failure(UUID player, UUID character, Throwable cause) { }
|
||||
private final CharacterStorage characters;
|
||||
private final ProgressionRules rules;
|
||||
private final games.dmg.fantasystick.session.EquipmentSessions sessions;
|
||||
private final PartyBookControls partyControls;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Failure> failures;
|
||||
@@ -28,16 +29,22 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
private volatile boolean closed;
|
||||
private static final int VOLUME_SIZE = 80; // Leave room for navigation and the bounded party pages.
|
||||
private static final net.kyori.adventure.key.Key PAGES = net.kyori.adventure.key.Key.key("fantasystick", "character_pages");
|
||||
private static final net.kyori.adventure.key.Key STATS = net.kyori.adventure.key.Key.key("fantasystick", "character_stat");
|
||||
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 record View(String nonce, ControlInput input, int previous, int next) { }
|
||||
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision) { }
|
||||
private final Map<UUID, View> views = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, StatView> statViews = new ConcurrentHashMap<>();
|
||||
|
||||
/** Scheduler must always defer to the server thread. Diagnostics must not access players or throw. */
|
||||
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
|
||||
games.dmg.fantasystick.session.EquipmentSessions sessions,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> failures, games.dmg.fantasystick.party.PartyRoster parties,
|
||||
org.bukkit.Server server, java.util.function.Function<UUID, Component> names) {
|
||||
this.characters = Objects.requireNonNull(characters);
|
||||
this.rules = Objects.requireNonNull(rules);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
this.partyControls = new PartyBookControls(parties, server, names, this::accept, this::report);
|
||||
@@ -51,6 +58,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
var owner = input.session().player();
|
||||
partyControls.forget(owner);
|
||||
views.remove(owner);
|
||||
statViews.remove(owner);
|
||||
Object ticket = new Object();
|
||||
pending.put(owner, ticket);
|
||||
characters.find(input.session().character()).whenComplete((record, failure) -> {
|
||||
@@ -96,6 +104,16 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
int start = Math.min(offset, (all.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
|
||||
int end = Math.min(start + VOLUME_SIZE, all.size());
|
||||
var pages = new java.util.ArrayList<Component>(all.subList(start, end));
|
||||
var statView = new StatView(UUID.randomUUID().toString(), input, record.revision());
|
||||
statViews.put(input.session().player(), statView);
|
||||
Component purchases = Component.text("Spend 1 point\n\n");
|
||||
for (var stat : Stat.values()) {
|
||||
purchases = purchases.append(Component.text("[+1 " + stat + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(STATS,
|
||||
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + statView.nonce() + ":" + stat + "\""))))
|
||||
.append(Component.newline());
|
||||
}
|
||||
pages.add(purchases);
|
||||
if (all.size() > VOLUME_SIZE) {
|
||||
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
|
||||
end < all.size() ? end : -1);
|
||||
@@ -139,6 +157,63 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
catch (RuntimeException ignored) { /* A diagnostic failure must not replay a book request. */ }
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
public void statAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
|
||||
if (closed || !STATS.equals(event.getIdentifier())
|
||||
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
|
||||
var player = connection.getPlayer();
|
||||
var view = statViews.get(player.getUniqueId());
|
||||
if (view == null || view.input().player() != player || event.getTag() == null) return;
|
||||
String raw = event.getTag().string();
|
||||
if (raw.length() > 80) return;
|
||||
var match = STAT_PAYLOAD.matcher(raw);
|
||||
if (!match.matches() || !view.nonce().equals(match.group(1))
|
||||
|| !statViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
|
||||
var stat = Stat.valueOf(match.group(2));
|
||||
var currentHealth = java.math.BigDecimal.valueOf(player.getHealth());
|
||||
var nativeLimit = NativeHealthLimit.read();
|
||||
characters.purchaseStat(view.input().session().character(), view.revision(), stat, currentHealth, nativeLimit)
|
||||
.whenComplete((purchase, failure) -> {
|
||||
try {
|
||||
nextTick.accept(() -> finishPurchase(view, stat, purchase, failure));
|
||||
} catch (RuntimeException rejected) { report(view.input(), rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void finishPurchase(StatView view, Stat stat, CharacterStorage.StatPurchase purchase, Throwable failure) {
|
||||
var input = view.input();
|
||||
if (closed || !input.stillCurrent()) return;
|
||||
if (failure != null) {
|
||||
report(input, failure);
|
||||
input.player().sendMessage("Stat purchase could not be saved; no character state was changed.");
|
||||
return;
|
||||
}
|
||||
if (purchase.status() == CharacterStorage.StatPurchaseStatus.APPLIED) {
|
||||
var saved = purchase.record().orElseThrow();
|
||||
try {
|
||||
if (!sessions.acknowledgeProgress(input.session(), view.revision(), saved)) {
|
||||
input.player().sendMessage("Stat purchase was saved; reopen the character after the session transition.");
|
||||
return;
|
||||
}
|
||||
} catch (RuntimeException nativeFailure) {
|
||||
report(input, nativeFailure);
|
||||
input.player().sendMessage("Stat purchase was saved, but native health refresh failed; exit for recovery.");
|
||||
return;
|
||||
}
|
||||
input.player().sendMessage(stat + " increased to " + saved.stats().get(stat) + ".");
|
||||
load(input, 0);
|
||||
return;
|
||||
}
|
||||
String message = switch (purchase.status()) {
|
||||
case STALE -> "That character page is stale; reopen it before spending points.";
|
||||
case NO_POINTS -> "This character has no unspent points.";
|
||||
case UNAVAILABLE -> "This character is no longer available.";
|
||||
case APPLIED -> throw new IllegalStateException("Handled applied purchase");
|
||||
};
|
||||
input.player().sendMessage(message);
|
||||
load(input, 0);
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
public void partyAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) { partyControls.click(event); }
|
||||
|
||||
@@ -147,8 +222,9 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
var owner = event.getPlayer().getUniqueId();
|
||||
pending.remove(owner);
|
||||
views.remove(owner);
|
||||
statViews.remove(owner);
|
||||
partyControls.forget(owner);
|
||||
}
|
||||
|
||||
@Override public void close() { closed = true; pending.clear(); views.clear(); partyControls.close(); }
|
||||
@Override public void close() { closed = true; pending.clear(); views.clear(); statViews.clear(); partyControls.close(); }
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package games.dmg.fantasystick.combat;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
|
||||
/** Server-thread built-in attack consumer; the bound stick's vanilla attack is always contained upstream. */
|
||||
public final class BasicAttack implements Consumer<ControlInput>, Listener, AutoCloseable {
|
||||
private static final Duration CADENCE = Duration.ofSeconds(1);
|
||||
private final EquipmentSessions sessions;
|
||||
private final Clock clock;
|
||||
private final Map<UUID, Instant> readyAt = new HashMap<>();
|
||||
private Pending pending;
|
||||
private boolean closed;
|
||||
|
||||
private static final class Pending {
|
||||
final ControlInput input;
|
||||
final LivingEntity target;
|
||||
final Instant attackedAt;
|
||||
boolean accepted;
|
||||
Pending(ControlInput input, LivingEntity target, Instant attackedAt) {
|
||||
this.input = input; this.target = target; this.attackedAt = attackedAt;
|
||||
}
|
||||
}
|
||||
|
||||
public BasicAttack(EquipmentSessions sessions) {
|
||||
this(sessions, Clock.systemUTC());
|
||||
}
|
||||
|
||||
public BasicAttack(EquipmentSessions sessions, Clock clock) {
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
@Override public void accept(ControlInput input) {
|
||||
if (closed || pending != null || input.kind() != ControlInput.Kind.BASIC_ATTACK
|
||||
|| input.gesture() != ControlInput.Gesture.ATTACK
|
||||
|| !(input.target() instanceof LivingEntity target) || !input.stillCurrent()) return;
|
||||
var record = sessions.currentRecord(input.session()).orElse(null);
|
||||
if (record == null) return;
|
||||
Instant now = clock.instant();
|
||||
Instant ready = readyAt.get(input.session().character());
|
||||
if (ready != null && now.isBefore(ready)) return;
|
||||
BigInteger damage = damage(record.stats().get(Stat.STR));
|
||||
double nativeDamage = damage.bitLength() > 1023 ? Double.MAX_VALUE : damage.doubleValue();
|
||||
var attempt = new Pending(input, target, now);
|
||||
pending = attempt;
|
||||
try {
|
||||
target.damage(nativeDamage, input.player());
|
||||
} finally {
|
||||
if (pending == attempt) pending = null;
|
||||
if (attempt.accepted) readyAt.put(input.session().character(), attempt.attackedAt.plus(CADENCE));
|
||||
}
|
||||
}
|
||||
|
||||
static BigInteger damage(BigInteger strength) {
|
||||
Objects.requireNonNull(strength);
|
||||
if (strength.signum() < 0) throw new IllegalArgumentException("Strength must not be negative");
|
||||
return strength.subtract(BigInteger.TEN).divide(BigInteger.TWO).max(BigInteger.ONE);
|
||||
}
|
||||
|
||||
/** MONITOR observes the synchronous native result; cancelled/zero damage never spends cadence. */
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageByEntityEvent event) {
|
||||
var attempt = pending;
|
||||
if (closed || attempt == null || event.isCancelled() || event.getFinalDamage() <= 0
|
||||
|| event.getDamager() != attempt.input.player() || event.getEntity() != attempt.target
|
||||
|| !attempt.input.stillCurrent() || !sessions.current(attempt.input.session())) return;
|
||||
attempt.accepted = true;
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
pending = null;
|
||||
readyAt.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Clock;
|
||||
@@ -210,6 +212,91 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
public enum ExperienceAwardStatus { APPLIED, AT_CAP, UNCHANGED, STALE, UNAVAILABLE }
|
||||
public record ExperienceAward(ExperienceAwardStatus status, Optional<CharacterRecord> record, int levels) { }
|
||||
|
||||
/** Persist configured transferable XP/level/point progression as one expected revision. */
|
||||
public CompletableFuture<ExperienceAward> awardExperience(UUID characterId, BigInteger expectedRevision,
|
||||
BigInteger reward, ProgressionRules rules) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Objects.requireNonNull(expectedRevision);
|
||||
Objects.requireNonNull(reward);
|
||||
Objects.requireNonNull(rules);
|
||||
if (reward.signum() < 0) throw new IllegalArgumentException("Experience reward must not be negative");
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) {
|
||||
return new ExperienceAward(ExperienceAwardStatus.UNAVAILABLE, Optional.empty(), 0);
|
||||
}
|
||||
var old = found.orElseThrow();
|
||||
if (!old.revision().equals(expectedRevision)) {
|
||||
return new ExperienceAward(ExperienceAwardStatus.STALE, Optional.of(old), 0);
|
||||
}
|
||||
var cap = rules.xpPerLevel().multiply(BigInteger.valueOf(rules.maximumLevel() - 1L));
|
||||
if (old.level() >= rules.maximumLevel() || old.experience().compareTo(cap) >= 0) {
|
||||
return new ExperienceAward(ExperienceAwardStatus.AT_CAP, Optional.of(old), 0);
|
||||
}
|
||||
var experience = old.experience().add(reward).min(cap);
|
||||
if (experience.equals(old.experience())) {
|
||||
var status = old.level() >= rules.maximumLevel()
|
||||
? ExperienceAwardStatus.AT_CAP : ExperienceAwardStatus.UNCHANGED;
|
||||
return new ExperienceAward(status, Optional.of(old), 0);
|
||||
}
|
||||
int level = experience.divide(rules.xpPerLevel()).intValueExact() + 1;
|
||||
int gained = Math.max(0, level - old.level());
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
|
||||
old.loadout(), old.cooldowns(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new ExperienceAward(ExperienceAwardStatus.APPLIED, Optional.of(saved), gained);
|
||||
});
|
||||
}
|
||||
|
||||
public enum StatPurchaseStatus { APPLIED, STALE, NO_POINTS, UNAVAILABLE }
|
||||
public record StatPurchase(StatPurchaseStatus status, Optional<CharacterRecord> record) { }
|
||||
|
||||
/** Persist one active-session stat purchase and its current native health as one revision. */
|
||||
public CompletableFuture<StatPurchase> purchaseStat(UUID characterId, BigInteger expectedRevision, Stat stat,
|
||||
java.math.BigDecimal currentHealth,
|
||||
java.math.BigDecimal nativeLimit) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Objects.requireNonNull(expectedRevision);
|
||||
Objects.requireNonNull(stat);
|
||||
Objects.requireNonNull(currentHealth);
|
||||
Objects.requireNonNull(nativeLimit);
|
||||
if (currentHealth.signum() < 0 || nativeLimit.signum() <= 0) {
|
||||
throw new IllegalArgumentException("Invalid stat-purchase health bound");
|
||||
}
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) {
|
||||
return new StatPurchase(StatPurchaseStatus.UNAVAILABLE, Optional.empty());
|
||||
}
|
||||
var old = found.orElseThrow();
|
||||
if (!old.revision().equals(expectedRevision)) {
|
||||
return new StatPurchase(StatPurchaseStatus.STALE, Optional.of(old));
|
||||
}
|
||||
if (old.unspentPoints().signum() == 0) {
|
||||
return new StatPurchase(StatPurchaseStatus.NO_POINTS, Optional.of(old));
|
||||
}
|
||||
var oldMaximum = old.maximumHealth().min(nativeLimit);
|
||||
if (currentHealth.compareTo(oldMaximum) > 0) {
|
||||
return new StatPurchase(StatPurchaseStatus.STALE, Optional.of(old));
|
||||
}
|
||||
var stats = new java.util.EnumMap<Stat, BigInteger>(Stat.class);
|
||||
stats.putAll(old.stats());
|
||||
stats.put(stat, stats.get(stat).add(BigInteger.ONE));
|
||||
var rawMaximum = new java.math.BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN));
|
||||
var gain = rawMaximum.min(nativeLimit).subtract(oldMaximum);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
|
||||
old.level(), old.experience(), old.unspentPoints().subtract(BigInteger.ONE),
|
||||
currentHealth.add(gain), old.learned(), old.loadout(), old.cooldowns(), false,
|
||||
old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new StatPurchase(StatPurchaseStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
}
|
||||
|
||||
/** Session-owned remaining health; retain all other current fields and reject concurrent revision changes. */
|
||||
public CompletableFuture<Optional<CharacterRecord>> saveHealth(UUID characterId, java.math.BigDecimal health,
|
||||
java.math.BigDecimal nativeLimit) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import games.dmg.fantasystick.items.SessionControls;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
@@ -201,6 +202,26 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
&& sheets.identify(attempt.player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent();
|
||||
}
|
||||
|
||||
/** Current transferable character snapshot for a still-authorized server-thread action. */
|
||||
public synchronized Optional<CharacterRecord> currentRecord(Active session) {
|
||||
var attempt = active.get(session.player());
|
||||
return current(session) && attempt != null && attempt.record != null
|
||||
? Optional.of(attempt.record) : Optional.empty();
|
||||
}
|
||||
|
||||
/** Apply a persisted progression revision to the still-current active session. */
|
||||
public synchronized boolean acknowledgeProgress(Active session, BigInteger expectedRevision, CharacterRecord saved) {
|
||||
var attempt = active.get(session.player());
|
||||
if (closed || attempt == null || attempt.session == null || attempt.record == null
|
||||
|| !attempt.session.equals(session.session()) || !attempt.character.equals(session.character())
|
||||
|| !attempt.record.revision().equals(expectedRevision) || !saved.id().equals(attempt.character)
|
||||
|| !saved.revision().equals(expectedRevision.add(BigInteger.ONE)) || !current(session)) return false;
|
||||
var previous = attempt.record;
|
||||
if (vitals != null) vitals.updateProgress(attempt.player, attempt.session, previous, saved);
|
||||
attempt.record = saved;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Server-thread authorization for the currently held, issued control and saved loadout. */
|
||||
public synchronized Optional<SelectedControl> selectedControl(Player player, ItemStack reported) {
|
||||
var attempt = active.get(player.getUniqueId());
|
||||
|
||||
@@ -69,6 +69,30 @@ public final class SessionVitals implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply an acknowledged stat revision to the live native health pool exactly once. */
|
||||
public void updateProgress(Player player, UUID session, CharacterRecord previous, CharacterRecord saved) {
|
||||
Objects.requireNonNull(previous);
|
||||
Objects.requireNonNull(saved);
|
||||
var mark = read(player);
|
||||
var lease = leases.get(player.getUniqueId());
|
||||
if (closed || mark == null || mark.phase() != Phase.APPLIED || !mark.session().equals(session)
|
||||
|| lease == null || !lease.session().equals(session) || !previous.id().equals(saved.id())
|
||||
|| !previous.revision().add(java.math.BigInteger.ONE).equals(saved.revision())) {
|
||||
throw new IllegalStateException("No matching active fantasy vitals revision");
|
||||
}
|
||||
double nextMaximum = saved.maximumHealth().min(NativeHealthLimit.read()).doubleValue();
|
||||
double gain = Math.max(0, nextMaximum - mark.maximum());
|
||||
if (gain == 0 && nextMaximum == mark.maximum()) return;
|
||||
var nativeAttribute = Objects.requireNonNull(((CraftPlayer) player).getHandle()
|
||||
.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH));
|
||||
lease.maximum().close();
|
||||
var replacement = new NativeMaximumHealth(nativeAttribute, nextMaximum);
|
||||
leases.put(player.getUniqueId(), new Lease(session, replacement));
|
||||
player.setHealth(Math.min(nextMaximum, player.getHealth() + gain));
|
||||
player.setHealthScale(nextMaximum);
|
||||
write(player, new Mark(session, Phase.APPLIED, nextMaximum, mark.ordinary()));
|
||||
}
|
||||
|
||||
public BigDecimal remaining(Player player, UUID session, OrdinaryVitals ordinary) {
|
||||
var mark = read(player);
|
||||
if (mark == null || !mark.session().equals(session) || !mark.ordinary().equals(ordinary) || mark.phase() != Phase.APPLIED) {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Captured when a new fantasy session starts; changing this does not alter active sessions.
|
||||
# Admin command: /fantasystick config adventure-mode [true|false]
|
||||
adventure-mode: true
|
||||
|
||||
# New-character and transferable fantasy progression defaults.
|
||||
starting-points: 40
|
||||
xp-per-level: 100
|
||||
maximum-level: 30
|
||||
@@ -0,0 +1,114 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class BasicAttackNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void successfulHitsStartAnExactOneSecondCadenceButCancelledDamageDoesNot() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
var active = sessions.active(a.core.id).orElseThrow();
|
||||
var clock = new MutableClock(Instant.parse("2026-09-19T00:00:00Z"));
|
||||
var basic = new games.dmg.fantasystick.combat.BasicAttack(sessions, clock);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
var cancelled = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
doAnswer(call -> {
|
||||
var source = mock(org.bukkit.damage.DamageSource.class);
|
||||
when(source.getCausingEntity()).thenReturn(a.player);
|
||||
when(source.getDirectEntity()).thenReturn(a.player);
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, (Double) call.getArgument(0));
|
||||
event.setCancelled(cancelled.get());
|
||||
basic.acceptedDamage(event);
|
||||
return null;
|
||||
}).when(target).damage(anyDouble(), same(a.player));
|
||||
var input = new games.dmg.fantasystick.session.ControlInput(a.player, active, 1, "",
|
||||
games.dmg.fantasystick.session.ControlInput.Kind.BASIC_ATTACK,
|
||||
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target, 0, 1,
|
||||
() -> sessions.current(active));
|
||||
basic.accept(input); // Cancelled: no cadence spent.
|
||||
cancelled.set(false);
|
||||
basic.accept(input); // Accepted at t=0.
|
||||
clock.advance(java.time.Duration.ofMillis(999)); basic.accept(input);
|
||||
verify(target, times(2)).damage(1.0, a.player);
|
||||
clock.advance(java.time.Duration.ofMillis(1)); basic.accept(input);
|
||||
verify(target, times(3)).damage(1.0, a.player);
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a);
|
||||
clock.advance(java.time.Duration.ofSeconds(1)); basic.accept(input);
|
||||
verifyNoMoreInteractions(target);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void partyCancellationPreventsDamageAndDoesNotSpendThePackagedAttackCadence() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
|
||||
f.activate(a); f.activate(b);
|
||||
var parties = PartyLifecycleNativeTest.parties(f);
|
||||
var party = parties.create(a.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(b.core.id, parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
|
||||
var events = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
|
||||
doAnswer(call -> {
|
||||
var source = mock(org.bukkit.damage.DamageSource.class);
|
||||
when(source.getCausingEntity()).thenReturn(a.player);
|
||||
when(source.getDirectEntity()).thenReturn(a.player);
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, b.player,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, (Double) call.getArgument(0));
|
||||
events.add(event); f.dispatcher.accept(event); return null;
|
||||
}).when(b.player).damage(anyDouble(), same(a.player));
|
||||
a.core.inventory.getInventory().setSelectedSlot(1);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
|
||||
assertEquals(1, events.size()); assertTrue(events.getFirst().isCancelled());
|
||||
assertTrue(parties.leave(a.core.id, party.id()));
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
|
||||
assertEquals(2, events.size()); assertFalse(events.getLast().isCancelled());
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
|
||||
assertEquals(2, events.size(), "The accepted second hit starts cadence; the cancelled first hit did not");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void packagedBasicStickInputDealsOneLowStrengthHitWithoutVanillaContinuation() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(1);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
|
||||
f.dispatcher.accept(attack);
|
||||
assertTrue(attack.isCancelled(), "The icon stick must never continue into vanilla damage");
|
||||
verify(target).damage(1.0, a.player);
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
|
||||
assertEquals(org.bukkit.Material.DIAMOND_SWORD, a.core.inventory.getItem(0).getType());
|
||||
assertTrue(a.core.inventory.getItem(1) == null || a.core.inventory.getItem(1).isEmpty(),
|
||||
"Exit restores the ordinary item that occupied the basic-stick slot");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class MutableClock extends Clock {
|
||||
private Instant now;
|
||||
MutableClock(Instant now) { this.now = now; }
|
||||
void advance(java.time.Duration duration) { now = now.plus(duration); }
|
||||
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
|
||||
@Override public Clock withZone(ZoneId zone) { return this; }
|
||||
@Override public Instant instant() { return now; }
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,103 @@ class CharacterBookNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void bookPurchasePersistsConAndPointsAndAppliesTheAcknowledgedNativeHealthGain() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
a.player.setHealth(5);
|
||||
PartyBookNativeTest.open(f, a);
|
||||
var purchase = PartyBookNativeTest.button(a, "[+1 CON]");
|
||||
PartyBookNativeTest.click(f, a, purchase);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals(java.math.BigInteger.ONE, saved.stats().get(games.dmg.fantasystick.progression.Stat.CON));
|
||||
assertEquals(java.math.BigInteger.valueOf(39), saved.unspentPoints());
|
||||
assertEquals(0, saved.health().compareTo(java.math.BigDecimal.valueOf(6)));
|
||||
assertEquals(11, a.maximumHealth.getValue());
|
||||
assertEquals(6, a.player.getHealth());
|
||||
assertTrue(PartyBookNativeTest.text(a).contains("CON: 1"));
|
||||
assertTrue(PartyBookNativeTest.text(a).contains("Unspent points: 39"));
|
||||
PartyBookNativeTest.click(f, a, purchase);
|
||||
PartyBookNativeTest.drain(f);
|
||||
assertEquals(saved, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow(),
|
||||
"A rendered purchase action is single-use");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void conPurchaseKeepsItsOwnMaximumAndOneTimeGainDespiteExternalModifiers() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player);
|
||||
var before = new net.minecraft.world.entity.ai.attributes.AttributeModifier(
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath("test", "before_purchase"), 5,
|
||||
net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation.ADD_VALUE);
|
||||
a.maximumHealth.addTransientModifier(before);
|
||||
f.activate(a); assertEquals(10, a.maximumHealth.getValue());
|
||||
var during = new net.minecraft.world.entity.ai.attributes.AttributeModifier(
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath("test", "during_purchase"), 100,
|
||||
net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation.ADD_VALUE);
|
||||
a.maximumHealth.addPermanentModifier(during);
|
||||
assertEquals(10, a.maximumHealth.getValue());
|
||||
a.player.setHealth(5);
|
||||
PartyBookNativeTest.open(f, a);
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[+1 CON]"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(11, a.maximumHealth.getValue()); assertEquals(6, a.player.getHealth());
|
||||
assertEquals(java.util.Set.of(before, during), a.maximumHealth.getModifiers());
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
|
||||
assertEquals(java.util.Set.of(before, during), a.maximumHealth.getModifiers());
|
||||
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals(java.math.BigInteger.ONE, saved.stats().get(games.dmg.fantasystick.progression.Stat.CON));
|
||||
assertEquals(0, saved.health().compareTo(java.math.BigDecimal.valueOf(6)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedPurchaseChangesNothingAndAcknowledgedStatsSurviveReopenTransferAndReload() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a);
|
||||
a.player.setHealth(5);
|
||||
PartyBookNativeTest.open(f, a);
|
||||
var rejected = PartyBookNativeTest.button(a, "[+1 STR]");
|
||||
var before = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"), java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid());
|
||||
PartyBookNativeTest.click(f, a, rejected);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
}
|
||||
assertEquals(before, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
assertEquals(5, a.player.getHealth()); assertEquals(10, a.maximumHealth.getValue());
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("could not be saved")));
|
||||
PartyBookNativeTest.click(f, a, rejected); PartyBookNativeTest.drain(f);
|
||||
assertEquals(before, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
|
||||
PartyBookNativeTest.open(f, a);
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[+1 STR]"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals(java.math.BigInteger.ONE, saved.stats().get(games.dmg.fantasystick.progression.Stat.STR));
|
||||
assertEquals(java.math.BigInteger.valueOf(39), saved.unspentPoints());
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
|
||||
b.core.inventory.setItemInOffHand(sheet); f.activate(b);
|
||||
PartyBookNativeTest.open(f, b);
|
||||
assertTrue(PartyBookNativeTest.text(b).contains("STR: 1"));
|
||||
assertTrue(PartyBookNativeTest.text(b).contains("Unspent points: 39"));
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
f.players.clear(); f.plugin.setEnabled(true); f.players.put(b.core.id, b.player); f.reenter(b);
|
||||
PartyBookNativeTest.open(f, b);
|
||||
assertTrue(PartyBookNativeTest.text(b).contains("STR: 1"));
|
||||
assertEquals(saved.stats(), characters(f).find(saved.id()).get(5, TimeUnit.SECONDS).orElseThrow().stats());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativePagesKeepLargeNumbersUnicodeNamesAndUnknownAbilitiesReadableAndComplete() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
@@ -166,7 +263,7 @@ class CharacterBookNativeTest {
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a);
|
||||
click(f, a); drainReads(f); // Old holder has a queued result, not a delivered book.
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a);
|
||||
f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
f.awaitCharacterAndReceipt(a);
|
||||
var renamed = characters(f).renameCharacter(a.core.record.id(), "Traded 雪 character").get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
runQueued(f); assertTrue(a.books.isEmpty());
|
||||
b.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(renamed));
|
||||
|
||||
@@ -63,6 +63,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertEquals(3, fixture.timers.size(), "Presentation, combat-provenance cleanup and session particles");
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") >= 0);
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") < recipe);
|
||||
assertTrue(fixture.order.indexOf("BasicAttack") >= 0);
|
||||
assertTrue(fixture.order.indexOf("BasicAttack") < recipe);
|
||||
assertNotNull(fixture.command);
|
||||
assertEquals("EquipmentAdminCommand", fixture.command.getExecutor().getClass().getSimpleName());
|
||||
assertTrue(fixture.tasks.isEmpty());
|
||||
@@ -206,6 +208,21 @@ class FantasyStickLifecycleNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void invalidExperienceThresholdAndMaximumLevelFailBeforeInstallingGameplay() throws Exception {
|
||||
for (String configuration : List.of("xp-per-level: 0\n", "xp-per-level: -1\n",
|
||||
"maximum-level: 0\n", "maximum-level: 2147483648\n", "maximum-level: not-a-number\n")) {
|
||||
var directory = data.resolve(Integer.toHexString(configuration.hashCode()));
|
||||
java.nio.file.Files.createDirectories(directory);
|
||||
java.nio.file.Files.writeString(directory.resolve("config.yml"), configuration);
|
||||
try (var fixture = new Fixture(directory)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> fixture.plugin.setEnabled(true));
|
||||
assertTrue(fixture.order.isEmpty());
|
||||
assertTrue(fixture.recipes.isEmpty());
|
||||
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void installedGuardRejectsOnlyTheCharacterRecipeThroughTheNativeDispatcher() throws Exception {
|
||||
try (var fixture = new Fixture(data)) {
|
||||
@@ -721,6 +738,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertNull(saved.pending()); assertNull(saved.lastExit());
|
||||
} else {
|
||||
f.activate(a); sessions.exit(a.player);
|
||||
f.awaitCharacterAndReceipt(a);
|
||||
assertEquals(org.bukkit.GameMode.ADVENTURE, a.player.getGameMode());
|
||||
var saved = f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow();
|
||||
assertNotNull(saved.pending()); assertNotNull(saved.lastExit());
|
||||
@@ -790,7 +808,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) Fixture.field(f.plugin, "sessions");
|
||||
sessions.exit(a.player);
|
||||
assertEquals(org.bukkit.GameMode.CREATIVE, a.player.getGameMode());
|
||||
f.receipts().find(a.core.id).toCompletableFuture().get();
|
||||
f.awaitCharacterAndReceipt(a);
|
||||
var b = new Actor(data); f.players.put(b.core.id, b.player);
|
||||
b.core.gameMode = org.bukkit.GameMode.SURVIVAL;
|
||||
f.activate(b);
|
||||
@@ -807,27 +825,43 @@ class FantasyStickLifecycleNativeTest {
|
||||
if (!value.equals("default")) java.nio.file.Files.writeString(directory.resolve("config.yml"), "character-swap-cooldown-seconds: " + value + "\n");
|
||||
try (var f = new Fixture(directory)) {
|
||||
f.plugin.setEnabled(true); var a = new Actor(directory); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, (net.kyori.adventure.text.Component) null));
|
||||
var exit = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().lastExit();
|
||||
assertEquals(java.time.Duration.ofSeconds(value.equals("default") ? 900 : Long.parseLong(value)),
|
||||
java.time.Duration.between(exit.exitedAt(), exit.deadline()));
|
||||
assertEquals(a.core.record.id(), exit.characterId());
|
||||
var other = new Actor(directory);
|
||||
a.core.inventory.setItemInOffHand(other.core.inventory.getItemInOffHand().clone());
|
||||
var before = a.core.snapshot();
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, (net.kyori.adventure.text.Component) null));
|
||||
f.tick(); f.tick(); // Join receipt reconciliation, then actual coordinator eligibility.
|
||||
if (value.equals("0")) {
|
||||
f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"), "Configured zero reaches entry, not only persisted arithmetic");
|
||||
} else {
|
||||
assertArrayEquals(before, a.core.snapshot()); assertTrue(a.core.marker().endsWith("|RESTORED"));
|
||||
assertTrue(a.messages.getLast().matches("(?s).*Wait [1-9][0-9]* seconds.*same character may be resumed now.*"), a.messages::toString);
|
||||
var state = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
|
||||
assertNull(state.pending()); assertEquals(exit, state.lastExit());
|
||||
a.core.inventory.setItemInOffHand(new CharacterSheets().create(a.core.record));
|
||||
var characters = (CharacterStorage) Fixture.field(f.plugin, "characters");
|
||||
var worker = (java.util.concurrent.ExecutorService) Fixture.field(characters, "worker");
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
worker.execute(() -> {
|
||||
entered.countDown();
|
||||
try { release.await(); }
|
||||
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
|
||||
});
|
||||
try {
|
||||
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, (net.kyori.adventure.text.Component) null));
|
||||
assertNotNull(f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending(),
|
||||
"Quit restores native state but must retain ownership until health persistence completes");
|
||||
release.countDown();
|
||||
f.awaitCharacterAndReceipt(a);
|
||||
var exit = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().lastExit();
|
||||
assertEquals(java.time.Duration.ofSeconds(value.equals("default") ? 900 : Long.parseLong(value)),
|
||||
java.time.Duration.between(exit.exitedAt(), exit.deadline()));
|
||||
assertEquals(a.core.record.id(), exit.characterId());
|
||||
var other = new Actor(directory);
|
||||
a.core.inventory.setItemInOffHand(other.core.inventory.getItemInOffHand().clone());
|
||||
var before = a.core.snapshot();
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, (net.kyori.adventure.text.Component) null));
|
||||
f.tick(); f.tick(); f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"));
|
||||
}
|
||||
f.tick(); f.tick(); // Join receipt reconciliation, then actual coordinator eligibility.
|
||||
if (value.equals("0")) {
|
||||
f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"), "Configured zero reaches entry, not only persisted arithmetic");
|
||||
} else {
|
||||
assertArrayEquals(before, a.core.snapshot()); assertTrue(a.core.marker().endsWith("|RESTORED"));
|
||||
assertTrue(a.messages.getLast().matches("(?s).*Wait [1-9][0-9]* seconds.*same character may be resumed now.*"), a.messages::toString);
|
||||
var state = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
|
||||
assertNull(state.pending()); assertEquals(exit, state.lastExit());
|
||||
a.core.inventory.setItemInOffHand(new CharacterSheets().create(a.core.record));
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, (net.kyori.adventure.text.Component) null));
|
||||
f.tick(); f.tick(); f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"));
|
||||
}
|
||||
} finally { release.countDown(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1144,6 +1178,12 @@ class FantasyStickLifecycleNativeTest {
|
||||
games.dmg.fantasystick.persistence.EquipmentSessionStore receipts() {
|
||||
return (games.dmg.fantasystick.persistence.EquipmentSessionStore) field(plugin, "receipts");
|
||||
}
|
||||
void awaitCharacterAndReceipt(Actor actor) throws Exception {
|
||||
// A receipt read alone cannot await work submitted later by the character-save callback.
|
||||
((CharacterStorage) field(plugin, "characters")).find(actor.core.record.id())
|
||||
.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
receipts().find(actor.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
}
|
||||
void tick() throws Exception {
|
||||
var task = tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertNotNull(task, "Missing deferred main-thread callback"); task.run();
|
||||
|
||||
@@ -234,7 +234,7 @@ class PartyBookNativeTest {
|
||||
open(f, a); var oldDisband = button(a, "[Disband party]");
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a);
|
||||
f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
f.awaitCharacterAndReceipt(a);
|
||||
b.core.inventory.setItemInOffHand(sheet); f.activate(b);
|
||||
open(f, b);
|
||||
assertTrue(text(b).contains(a.core.record.name()));
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package games.dmg.fantasystick.combat;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class BasicAttackTest {
|
||||
@Test void followsTheApprovedStrengthFormulaWithoutAStatCap() {
|
||||
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.ZERO));
|
||||
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.TEN));
|
||||
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.valueOf(11)));
|
||||
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.valueOf(12)));
|
||||
assertEquals(BigInteger.TWO, BasicAttack.damage(BigInteger.valueOf(14)));
|
||||
var huge = BigInteger.TEN.pow(1000);
|
||||
assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO), BasicAttack.damage(huge));
|
||||
assertThrows(IllegalArgumentException.class, () -> BasicAttack.damage(BigInteger.valueOf(-1)));
|
||||
assertThrows(NullPointerException.class, () -> BasicAttack.damage(null));
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,35 @@ class CharacterStorageTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void awardsConfiguredExperiencePersistentlyWithMultiLevelAndCapIdempotence() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var rules = new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.valueOf(2), BigInteger.valueOf(7), 3);
|
||||
var live = new games.dmg.fantasystick.crafting.CharacterFactory().create(rules);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
storage.create(live).join();
|
||||
var awarded = storage.awardExperience(live.id(), live.revision(), BigInteger.valueOf(8), rules).join();
|
||||
assertEquals(CharacterStorage.ExperienceAwardStatus.APPLIED, awarded.status());
|
||||
assertEquals(1, awarded.levels());
|
||||
var levelTwo = awarded.record().orElseThrow();
|
||||
assertEquals(2, levelTwo.level());
|
||||
assertEquals(BigInteger.valueOf(8), levelTwo.experience());
|
||||
assertEquals(BigInteger.valueOf(3), levelTwo.unspentPoints());
|
||||
var capped = storage.awardExperience(live.id(), levelTwo.revision(), BigInteger.valueOf(100), rules).join();
|
||||
assertEquals(1, capped.levels());
|
||||
assertEquals(3, capped.record().orElseThrow().level());
|
||||
assertEquals(BigInteger.valueOf(14), capped.record().orElseThrow().experience());
|
||||
assertEquals(CharacterStorage.ExperienceAwardStatus.AT_CAP,
|
||||
storage.awardExperience(live.id(), capped.record().orElseThrow().revision(), BigInteger.ONE, rules).join().status());
|
||||
assertEquals(CharacterStorage.ExperienceAwardStatus.STALE,
|
||||
storage.awardExperience(live.id(), live.revision(), BigInteger.ONE, rules).join().status());
|
||||
var lowered = new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.ZERO, BigInteger.valueOf(7), 2);
|
||||
assertEquals(CharacterStorage.ExperienceAwardStatus.AT_CAP,
|
||||
storage.awardExperience(live.id(), capped.record().orElseThrow().revision(), BigInteger.ONE, lowered).join().status());
|
||||
assertEquals(capped.record().orElseThrow(), repository.load(live.id()).orElseThrow(),
|
||||
"Lowering configuration must not rewrite existing transferable progress");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedRevivalSaveLeavesDeadStateRetryableAndShutdownRejectsNewWork() throws Exception {
|
||||
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
|
||||
Reference in new issue
Block a user