Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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;
|
||||
@@ -67,9 +67,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,7 +114,7 @@ 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);
|
||||
@@ -225,6 +223,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(); }
|
||||
}
|
||||
@@ -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,19 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
&& sheets.identify(attempt.player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent();
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -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));
|
||||
|
||||
@@ -206,6 +206,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 +736,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 +806,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 +823,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 +1176,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()));
|
||||
|
||||
@@ -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