Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
624deb938d | ||
|
|
232b2a190d |
No files matched your search
@@ -81,6 +81,21 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
() -> characters.assignAbility(input.session().character(), expectedRevision, ability, slot));
|
||||
}
|
||||
|
||||
public CompletableFuture<Mutation> unlearn(ControlInput input, String ability, BigInteger expectedRevision) {
|
||||
Objects.requireNonNull(input); Objects.requireNonNull(expectedRevision);
|
||||
if (closed || registry.find(ability).isEmpty() || input.kind() != ControlInput.Kind.BOOK || !input.stillCurrent()) {
|
||||
return CompletableFuture.completedFuture(new Mutation(CharacterStorage.AbilityMutationStatus.UNAVAILABLE, null));
|
||||
}
|
||||
return mutation(input, ability, expectedRevision,
|
||||
() -> characters.unlearnAbility(input.session().character(), expectedRevision, ability))
|
||||
.thenApply(mutation -> {
|
||||
if (mutation.record() != null && !mutation.record().learned().contains(ability)) {
|
||||
remove(input.session().character(), ability);
|
||||
}
|
||||
return mutation;
|
||||
});
|
||||
}
|
||||
|
||||
private CompletableFuture<Mutation> mutation(ControlInput input, String ability, BigInteger expectedRevision,
|
||||
java.util.function.Supplier<CompletableFuture<CharacterStorage.AbilityMutation>> operation) {
|
||||
var result = new CompletableFuture<Mutation>();
|
||||
|
||||
@@ -34,6 +34,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
private static final net.kyori.adventure.key.Key STATS = net.kyori.adventure.key.Key.key("fantasystick", "character_stat");
|
||||
private static final net.kyori.adventure.key.Key ABILITY_UNLOCK = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlock");
|
||||
private static final net.kyori.adventure.key.Key ABILITY_ASSIGN = net.kyori.adventure.key.Key.key("fantasystick", "ability_assign");
|
||||
private static final net.kyori.adventure.key.Key ABILITY_UNLEARN = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlearn");
|
||||
private static final java.util.regex.Pattern PAGE_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([01])\"");
|
||||
private static final java.util.regex.Pattern STAT_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):(STR|DEX|CON|INT|WIS|CHR)\"");
|
||||
private static final java.util.regex.Pattern ABILITY_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9_]{0,63})(?::([0-6]))?\"");
|
||||
@@ -178,11 +179,13 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
if (!learned && stats && record.unspentPoints().compareTo(definition.pointCost()) >= 0) {
|
||||
pages.add(Component.text("Ability action\n\n").append(abilityButton("Learn", ABILITY_UNLOCK, view, definition.id(), null)));
|
||||
} else if (learned) {
|
||||
Component action = Component.text("Assign " + definition.name() + "\n\nSlot: ");
|
||||
Component action = Component.text("Assign ability\n\nSlot:\n");
|
||||
for (int slot = 0; slot < 7; slot++) {
|
||||
if (slot > 0) action = action.append(Component.space());
|
||||
if (slot > 0) action = action.append(slot == 4 ? Component.newline() : Component.space());
|
||||
action = action.append(abilityButton(Integer.toString(slot + 3), ABILITY_ASSIGN, view, definition.id(), slot));
|
||||
}
|
||||
action = action.append(Component.text("\n\nRefund: "))
|
||||
.append(abilityButton("Unlearn", ABILITY_UNLEARN, view, definition.id(), null));
|
||||
pages.add(action);
|
||||
}
|
||||
return java.util.List.copyOf(pages);
|
||||
@@ -285,7 +288,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
public void abilityAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
|
||||
boolean unlock = ABILITY_UNLOCK.equals(event.getIdentifier());
|
||||
boolean assign = ABILITY_ASSIGN.equals(event.getIdentifier());
|
||||
if (closed || abilityEngine == null || !unlock && !assign
|
||||
boolean unlearn = ABILITY_UNLEARN.equals(event.getIdentifier());
|
||||
if (closed || abilityEngine == null || !unlock && !assign && !unlearn
|
||||
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|
||||
|| event.getTag() == null) return;
|
||||
var player = connection.getPlayer();
|
||||
@@ -295,10 +299,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
if (raw.length() > 120) return;
|
||||
var match = ABILITY_PAYLOAD.matcher(raw);
|
||||
if (!match.matches() || !view.nonce().equals(match.group(1)) || !view.ids().contains(match.group(2))
|
||||
|| unlock != (match.group(3) == null)
|
||||
|| assign != (match.group(3) != null)
|
||||
|| !abilityViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
|
||||
String ability = match.group(2);
|
||||
var operation = unlock ? abilityEngine.purchase(view.input(), ability, view.revision())
|
||||
: unlearn ? abilityEngine.unlearn(view.input(), ability, view.revision())
|
||||
: abilityEngine.assign(view.input(), ability, Integer.parseInt(match.group(3)), view.revision());
|
||||
operation.whenComplete((mutation, failure) -> {
|
||||
if (closed || !view.input().stillCurrent()) return;
|
||||
@@ -307,19 +312,22 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
player.sendMessage("Ability change could not be confirmed; reopen the character after session recovery.");
|
||||
return;
|
||||
}
|
||||
player.sendMessage(abilityMessage(unlock, ability, mutation.status()));
|
||||
player.sendMessage(abilityMessage(unlock, unlearn, ability, mutation.status()));
|
||||
load(view.input(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
private static String abilityMessage(boolean unlock, String ability, CharacterStorage.AbilityMutationStatus status) {
|
||||
private static String abilityMessage(boolean unlock, boolean unlearn, String ability,
|
||||
CharacterStorage.AbilityMutationStatus status) {
|
||||
return switch (status) {
|
||||
case APPLIED -> unlock ? "Learned " + ability + "." : "Assigned " + ability + ".";
|
||||
case APPLIED -> unlock ? "Learned " + ability + "."
|
||||
: unlearn ? "Unlearned " + ability + " and refunded 1 point." : "Assigned " + ability + ".";
|
||||
case UNCHANGED -> ability + " is already assigned there.";
|
||||
case REQUIREMENTS -> "The purchased base-stat requirements are not met.";
|
||||
case NO_POINTS -> "This character does not have enough unspent points.";
|
||||
case ALREADY_LEARNED -> "This character already knows " + ability + ".";
|
||||
case NOT_LEARNED -> "Learn " + ability + " before assigning it.";
|
||||
case NOT_LEARNED -> unlearn ? "This character no longer knows " + ability + "."
|
||||
: "Learn " + ability + " before assigning it.";
|
||||
case STALE -> "That character page is stale; reopen it before changing abilities.";
|
||||
case COOLDOWN -> "That ability is still on cooldown.";
|
||||
case UNAVAILABLE -> "That character or ability is unavailable.";
|
||||
|
||||
@@ -326,6 +326,26 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
/** Refund one learned ability exactly once, clear every equipped reference and retain its deadline. */
|
||||
public CompletableFuture<AbilityMutation> unlearnAbility(UUID characterId, BigInteger expectedRevision, String ability) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(ability);
|
||||
if (ability.isBlank()) throw new IllegalArgumentException("Invalid ability refund");
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) return new AbilityMutation(AbilityMutationStatus.UNAVAILABLE, Optional.empty());
|
||||
var old = found.orElseThrow();
|
||||
if (!old.revision().equals(expectedRevision)) return new AbilityMutation(AbilityMutationStatus.STALE, Optional.of(old));
|
||||
if (!old.learned().contains(ability)) return new AbilityMutation(AbilityMutationStatus.NOT_LEARNED, Optional.of(old));
|
||||
var learned = new java.util.HashSet<>(old.learned()); learned.remove(ability);
|
||||
var loadout = old.loadout().stream().map(assigned -> assigned.equals(ability) ? "" : assigned).toList();
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints().add(BigInteger.ONE), old.health(), learned, loadout, old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
}
|
||||
|
||||
/** Move one learned ability to one of the seven selectable slots without duplicating it. */
|
||||
public CompletableFuture<AbilityMutation> assignAbility(UUID characterId, BigInteger expectedRevision,
|
||||
String ability, int slot) {
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
# Admin command: /fantasystick config adventure-mode [true|false]
|
||||
adventure-mode: true
|
||||
|
||||
# Real-time delay before this player may enter a different character after exit.
|
||||
character-swap-cooldown-seconds: 900
|
||||
|
||||
# New-character and transferable fantasy progression defaults.
|
||||
starting-points: 40
|
||||
xp-per-level: 100
|
||||
|
||||
@@ -101,6 +101,79 @@ class FireballNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void bookUnlearnRefundsOnceClearsOwnedFireballAndRetainsDeadlineThroughReuse() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withInt(data, a, 12);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Learn]"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[3]"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
org.mockito.Mockito.when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var projectile = projectile(a, new org.bukkit.Location(a.world, 1, 65.6, 0));
|
||||
var launches = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var eye = new org.bukkit.Location(a.world, 0, 65.6, 0); eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
|
||||
org.mockito.Mockito.doReturn(eye).when(a.player).getEyeLocation();
|
||||
org.mockito.Mockito.doAnswer(call -> { launches.incrementAndGet(); return projectile; }).when(a.player)
|
||||
.launchProjectile(org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Snowball.class),
|
||||
org.mockito.ArgumentMatchers.any(org.bukkit.util.Vector.class));
|
||||
cast(f, a);
|
||||
var cast = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
var deadline = cast.cooldowns().get("fireball"); assertNotNull(deadline); assertEquals(1, launches.get());
|
||||
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
for (var page : content.pages()) for (String line : page.raw().getString().split("\\n", -1)) {
|
||||
assertTrue(line.codePoints().map(cp -> cp < 128 ? 6 : 9).sum() <= 108,
|
||||
"Learned ability action exceeds native visible width: " + line);
|
||||
}
|
||||
var unlearn = PartyBookNativeTest.button(a, "[Unlearn]");
|
||||
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
|
||||
assertFalse(refunded.learned().contains("fireball"));
|
||||
assertTrue(refunded.loadout().stream().noneMatch("fireball"::equals));
|
||||
assertEquals(deadline, refunded.cooldowns().get("fireball"));
|
||||
org.mockito.Mockito.verify(projectile, org.mockito.Mockito.atLeastOnce()).remove();
|
||||
assertTrue(new games.dmg.fantasystick.items.SessionControls(
|
||||
(games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry"))
|
||||
.ability(a.core.inventory.getItem(2)).isEmpty());
|
||||
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[+1 WIS]"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var spent = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.ONE, spent.stats().get(games.dmg.fantasystick.progression.Stat.WIS));
|
||||
assertEquals(BigInteger.valueOf(39), spent.unspentPoints());
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Learn]"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[3]"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("fireball"));
|
||||
cast(f, a); assertEquals(1, launches.get());
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
|
||||
var transferredSheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(transferredSheet);
|
||||
f.players.put(b.core.id, b.player); f.activate(b);
|
||||
b.core.inventory.getInventory().setSelectedSlot(2);
|
||||
assertEquals("fireball", new games.dmg.fantasystick.items.SessionControls(
|
||||
(games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry"))
|
||||
.ability(b.core.inventory.getItem(2)).orElseThrow());
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(b.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
b.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
PartyBookNativeTest.drain(f);
|
||||
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")),
|
||||
"Transfer/reload must retain the original Fireball deadline after refund and relearn");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void partyAndMixedModeProjectileHitsPreserveProtectionCancellation() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "fireball-cooldown-seconds: 0\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
|
||||
@@ -33,6 +33,15 @@ class DistributionTest {
|
||||
assertEquals(1, metadata.getPermissions().size());
|
||||
assertEquals("fantasystick.admin", metadata.getPermissions().getFirst().getName());
|
||||
assertEquals(org.bukkit.permissions.PermissionDefault.OP, metadata.getPermissions().getFirst().getDefault());
|
||||
var configuration = jar.getJarEntry("config.yml");
|
||||
assertNotNull(configuration, "Ship reviewed session defaults for administrators");
|
||||
String defaults;
|
||||
try (var input = jar.getInputStream(configuration)) {
|
||||
defaults = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
assertTrue(defaults.contains("character-swap-cooldown-seconds: 900"),
|
||||
"The delivered configuration must expose the reviewed 15-minute default");
|
||||
assertTrue(defaults.contains("adventure-mode: true"));
|
||||
assertTrue(JavaPlugin.class.isAssignableFrom(FantasyStickPlugin.class));
|
||||
var entrypoint = jar.getJarEntry(metadata.getMain().replace('.', '/') + ".class");
|
||||
assertNotNull(entrypoint);
|
||||
|
||||
@@ -169,6 +169,32 @@ class CharacterStorageTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void abilityUnlearnRefundsExactlyOnePointClearsEverySlotAndPreservesCooldown() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var deadline = java.time.Instant.parse("2026-09-21T12:00:00Z");
|
||||
var learned = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
|
||||
base.experience(), BigInteger.valueOf(39), base.health(), java.util.Set.of("fireball"),
|
||||
java.util.List.of("fireball", "", "fireball", "", "", "", ""), java.util.Map.of("fireball", deadline),
|
||||
base.restrictedKills(), false, null, null);
|
||||
repository.create(learned);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var refunded = storage.unlearnAbility(learned.id(), learned.revision(), "fireball").join();
|
||||
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, refunded.status());
|
||||
var saved = refunded.record().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(40), saved.unspentPoints());
|
||||
assertFalse(saved.learned().contains("fireball"));
|
||||
assertTrue(saved.loadout().stream().noneMatch("fireball"::equals));
|
||||
assertEquals(deadline, saved.cooldowns().get("fireball"));
|
||||
assertEquals(saved, repository.load(saved.id()).orElseThrow());
|
||||
assertEquals(CharacterStorage.AbilityMutationStatus.NOT_LEARNED,
|
||||
storage.unlearnAbility(saved.id(), saved.revision(), "fireball").join().status());
|
||||
assertEquals(saved, repository.load(saved.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void abilityUnlockRefusesMissingRequirementsAndPointsWithoutChangingSavedState() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
@@ -227,6 +253,32 @@ class CharacterStorageTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedAbilityRefundLeavesLearnedStateAndPointsUnchangedForOneRetry() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var learned = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
|
||||
base.experience(), BigInteger.valueOf(39), base.health(), java.util.Set.of("fireball"),
|
||||
java.util.List.of("fireball", "", "", "", "", "", ""),
|
||||
java.util.Map.of("fireball", java.time.Instant.parse("2026-09-22T00:00:00Z")),
|
||||
base.restrictedKills(), false, null, null);
|
||||
repository.create(learned);
|
||||
var failing = new YamlCharacterRepository(data, (source, target) -> { throw new IOException("simulated refund failure"); });
|
||||
try (var storage = new CharacterStorage(failing, Clock.systemUTC())) {
|
||||
assertThrows(CompletionException.class,
|
||||
() -> storage.unlearnAbility(learned.id(), learned.revision(), "fireball").join());
|
||||
}
|
||||
assertEquals(learned, repository.load(learned.id()).orElseThrow());
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var refund = storage.unlearnAbility(learned.id(), learned.revision(), "fireball").join();
|
||||
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, refund.status());
|
||||
assertEquals(BigInteger.valueOf(40), refund.record().orElseThrow().unspentPoints());
|
||||
assertEquals(CharacterStorage.StatPurchaseStatus.STALE,
|
||||
storage.purchaseStat(learned.id(), learned.revision(), games.dmg.fantasystick.progression.Stat.STR,
|
||||
learned.health(), new java.math.BigDecimal("1024")).join().status());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedAbilityUnlockSaveLeavesPointsAndLearnedStateUnchangedForRetry() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
|
||||
Reference in new issue
Block a user