Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4517aa3a64 | ||
|
|
89a57167cc | ||
|
|
9f5fede5e5 |
No files matched your search
@@ -221,6 +221,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
/** Bounded lifecycle sweep; plugin timers may call this without scanning future catalogues. */
|
||||
public void sweep() {
|
||||
if (closed) return;
|
||||
sessions.refreshCooldownControls(64);
|
||||
int checked = 0;
|
||||
outer: for (var runtimes : List.copyOf(active.values())) {
|
||||
for (var runtime : List.copyOf(runtimes)) {
|
||||
|
||||
@@ -9,6 +9,15 @@ public final class BookPagination {
|
||||
private BookPagination() { }
|
||||
|
||||
public static List<Component> pages(String text) {
|
||||
var lines = lines(text);
|
||||
var pages = new ArrayList<Component>();
|
||||
for (int start = 0; start < lines.size(); start += 13) {
|
||||
pages.add(Component.text(String.join("\n", lines.subList(start, Math.min(start + 13, lines.size())))));
|
||||
}
|
||||
return List.copyOf(pages);
|
||||
}
|
||||
|
||||
static List<String> lines(String text) {
|
||||
var lines = new ArrayList<String>();
|
||||
var line = new StringBuilder();
|
||||
int width = 0;
|
||||
@@ -28,10 +37,6 @@ public final class BookPagination {
|
||||
line.appendCodePoint(codePoint); width += advance;
|
||||
}
|
||||
lines.add(line.toString());
|
||||
var pages = new ArrayList<Component>();
|
||||
for (int start = 0; start < lines.size(); start += 13) {
|
||||
pages.add(Component.text(String.join("\n", lines.subList(start, Math.min(start + 13, lines.size())))));
|
||||
}
|
||||
return List.copyOf(pages);
|
||||
return List.copyOf(lines);
|
||||
}
|
||||
}
|
||||
@@ -29,15 +29,14 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
private final games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
|
||||
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
|
||||
private volatile boolean closed;
|
||||
private static final int VOLUME_SIZE = 80; // Leave room for navigation and the bounded party pages.
|
||||
private static final int VOLUME_SIZE = 30; // Reserve room for linked ability details, navigation and 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 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]))?\"");
|
||||
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})\"");
|
||||
private record View(String nonce, ControlInput input, int previous, int next) { }
|
||||
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision) { }
|
||||
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
|
||||
@@ -107,47 +106,47 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
}
|
||||
|
||||
private ItemStack render(CharacterRecord record, ControlInput input, int offset) {
|
||||
var first = new StringBuilder(record.name()).append("\n\n");
|
||||
for (var stat : Stat.values()) first.append(stat).append(": ").append(record.stats().get(stat)).append('\n');
|
||||
var maximum = record.maximumHealth().min(NativeHealthLimit.read());
|
||||
String xp = record.level() >= rules.maximumLevel() ? "MAX"
|
||||
: record.experience().remainder(rules.xpPerLevel()) + "/" + rules.xpPerLevel();
|
||||
String learned = record.learned().isEmpty() ? "none" : String.join(", ", record.learned().stream().sorted().toList());
|
||||
String second = "Level: " + record.level() + "\nXP: " + xp + "\nUnspent points: " + record.unspentPoints()
|
||||
+ "\nHealth: " + java.math.BigDecimal.valueOf(input.player().getHealth()).min(maximum).stripTrailingZeros().toPlainString()
|
||||
+ "/" + maximum.stripTrailingZeros().toPlainString()
|
||||
+ "\n\nAbilities: " + learned;
|
||||
var item = new ItemStack(Material.WRITTEN_BOOK);
|
||||
var meta = (BookMeta) item.getItemMeta();
|
||||
meta.setTitle("Character");
|
||||
meta.setAuthor("Fantasy Stick");
|
||||
var all = BookPagination.pages(first + "\n" + second);
|
||||
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);
|
||||
var definitions = abilities.definitions();
|
||||
if (!definitions.isEmpty() && end == all.size()) {
|
||||
var allOverview = overviewPages(record, input, statView);
|
||||
int start = Math.min(offset, (allOverview.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
|
||||
int end = Math.min(start + VOLUME_SIZE, allOverview.size());
|
||||
var pages = new java.util.ArrayList<Component>(allOverview.subList(start, end));
|
||||
if (record.unspentPoints().signum() > 0 && start == 0) statViews.put(input.session().player(), statView);
|
||||
|
||||
var definitions = abilities.definitions().stream()
|
||||
.sorted(java.util.Comparator.comparing(games.dmg.fantasystick.ability.AbilityDefinition::name)
|
||||
.thenComparing(games.dmg.fantasystick.ability.AbilityDefinition::id))
|
||||
.toList();
|
||||
if (!definitions.isEmpty() && end == allOverview.size()) {
|
||||
var abilityView = new AbilityView(UUID.randomUUID().toString(), input, record.revision(),
|
||||
definitions.stream().map(games.dmg.fantasystick.ability.AbilityDefinition::id)
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet()));
|
||||
abilityViews.put(input.session().player(), abilityView);
|
||||
for (var definition : definitions) pages.addAll(abilityPages(record, definition, abilityView));
|
||||
var details = definitions.stream().map(definition -> abilityPages(record, definition, abilityView)).toList();
|
||||
var placeholderTargets = definitions.stream().collect(java.util.stream.Collectors.toMap(
|
||||
games.dmg.fantasystick.ability.AbilityDefinition::id, ignored -> 1));
|
||||
int activeCount = activePages(record, definitions, placeholderTargets).size();
|
||||
int indexCount = indexPages(definitions, placeholderTargets).size();
|
||||
var targets = new java.util.HashMap<String, Integer>();
|
||||
int target = pages.size() + activeCount + indexCount + 1;
|
||||
for (int index = 0; index < definitions.size(); index++) {
|
||||
targets.put(definitions.get(index).id(), target);
|
||||
target += details.get(index).size();
|
||||
}
|
||||
pages.addAll(activePages(record, definitions, targets));
|
||||
pages.addAll(indexPages(definitions, targets));
|
||||
details.forEach(pages::addAll);
|
||||
}
|
||||
if (all.size() > VOLUME_SIZE) {
|
||||
if (allOverview.size() > VOLUME_SIZE) {
|
||||
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
|
||||
end < all.size() ? end : -1);
|
||||
end < allOverview.size() ? end : -1);
|
||||
views.put(input.session().player(), view);
|
||||
Component navigation = Component.text("Character pages\n\n" + (start + 1) + "–" + end + "/" + all.size());
|
||||
Component navigation = Component.text("Character pages\n\n" + (start + 1) + "–" + end + "/" + allOverview.size());
|
||||
if (view.previous() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Previous pages", view, 0));
|
||||
if (view.next() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Next pages", view, 1));
|
||||
pages.add(navigation);
|
||||
@@ -158,6 +157,70 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
return item;
|
||||
}
|
||||
|
||||
private java.util.List<Component> overviewPages(CharacterRecord record, ControlInput input, StatView view) {
|
||||
var builder = new PageBuilder();
|
||||
builder.text(record.name());
|
||||
builder.blank();
|
||||
for (var stat : Stat.values()) {
|
||||
Component action = record.unspentPoints().signum() > 0
|
||||
? statButton(stat, view) : null;
|
||||
builder.inline(stat + ": " + record.stats().get(stat), action);
|
||||
}
|
||||
var maximum = record.maximumHealth().min(NativeHealthLimit.read());
|
||||
String xp = record.level() >= rules.maximumLevel() ? "MAX"
|
||||
: record.experience().remainder(rules.xpPerLevel()) + "/" + rules.xpPerLevel();
|
||||
builder.text("Level: " + record.level());
|
||||
builder.text("XP: " + xp);
|
||||
builder.text("Unspent points: " + record.unspentPoints());
|
||||
builder.text("Health: " + java.math.BigDecimal.valueOf(input.player().getHealth()).min(maximum).stripTrailingZeros().toPlainString()
|
||||
+ "/" + maximum.stripTrailingZeros().toPlainString());
|
||||
return builder.pages();
|
||||
}
|
||||
|
||||
private static Component statButton(Stat stat, StatView view) {
|
||||
return 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("\"" + view.nonce() + ":" + stat + "\"")));
|
||||
}
|
||||
|
||||
private static java.util.List<Component> activePages(CharacterRecord record,
|
||||
java.util.List<games.dmg.fantasystick.ability.AbilityDefinition> definitions, Map<String, Integer> targets) {
|
||||
var byId = definitions.stream().collect(java.util.stream.Collectors.toMap(
|
||||
games.dmg.fantasystick.ability.AbilityDefinition::id, java.util.function.Function.identity()));
|
||||
var builder = new PageBuilder();
|
||||
builder.text("Active abilities"); builder.blank();
|
||||
for (int slot = 0; slot < record.loadout().size(); slot++) {
|
||||
String id = record.loadout().get(slot);
|
||||
var definition = byId.get(id);
|
||||
if (id.isEmpty()) builder.text((slot + 3) + ": No Ability");
|
||||
else if (definition == null) builder.text((slot + 3) + ": " + id);
|
||||
else builder.link((slot + 3) + ": ", definition.name(), targets.get(id));
|
||||
}
|
||||
var unassigned = record.learned().stream().filter(id -> !record.loadout().contains(id)).sorted().toList();
|
||||
if (!unassigned.isEmpty()) {
|
||||
builder.blank(); builder.text("Unassigned learned:");
|
||||
for (var id : unassigned) {
|
||||
var definition = byId.get(id);
|
||||
if (definition == null) builder.text(id);
|
||||
else builder.link("", definition.name(), targets.get(id));
|
||||
}
|
||||
}
|
||||
return builder.pages();
|
||||
}
|
||||
|
||||
private static java.util.List<Component> indexPages(
|
||||
java.util.List<games.dmg.fantasystick.ability.AbilityDefinition> definitions, Map<String, Integer> targets) {
|
||||
var pages = new java.util.ArrayList<Component>();
|
||||
for (int start = 0; start < definitions.size(); start += 11) {
|
||||
var builder = new PageBuilder(); builder.text("Ability index"); builder.blank();
|
||||
for (var definition : definitions.subList(start, Math.min(start + 11, definitions.size()))) {
|
||||
builder.link("", definition.name(), targets.get(definition.id()));
|
||||
}
|
||||
pages.addAll(builder.pages());
|
||||
}
|
||||
return java.util.List.copyOf(pages);
|
||||
}
|
||||
|
||||
private static java.util.List<Component> abilityPages(CharacterRecord record,
|
||||
games.dmg.fantasystick.ability.AbilityDefinition definition, AbilityView view) {
|
||||
String requirements = definition.requirements().isEmpty() ? "none" : definition.requirements().entrySet().stream()
|
||||
@@ -166,29 +229,70 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
boolean learned = record.learned().contains(definition.id());
|
||||
boolean stats = definition.requirements().entrySet().stream()
|
||||
.allMatch(entry -> record.stats().get(entry.getKey()).compareTo(entry.getValue()) >= 0);
|
||||
boolean points = record.unspentPoints().compareTo(definition.pointCost()) >= 0;
|
||||
boolean slot = record.loadout().contains("");
|
||||
String state = learned ? "Learned" : !stats ? "Unavailable: requirements"
|
||||
: record.unspentPoints().compareTo(definition.pointCost()) < 0 ? "Unavailable: points" : "Available";
|
||||
String parameters = definition.parameters().isEmpty() ? "" : "\nParameters: " + definition.parameters().entrySet().stream()
|
||||
: !points ? "Unavailable: points" : !slot ? "Unavailable: slots" : "Available";
|
||||
var builder = new PageBuilder();
|
||||
builder.text(definition.name());
|
||||
builder.text(requirements);
|
||||
if (learned) builder.component(abilityButton("Unlearn", ABILITY_UNLEARN, view, definition.id()));
|
||||
else if (stats && points && slot) builder.component(abilityButton("Learn", ABILITY_UNLOCK, view, definition.id()));
|
||||
else builder.text(state);
|
||||
builder.blank();
|
||||
definition.lore().forEach(builder::text);
|
||||
builder.blank(); builder.text(definition.instructions());
|
||||
builder.text("Cooldown: " + durationSeconds(definition.cooldown()) + "s");
|
||||
if (!definition.parameters().isEmpty()) builder.text("Parameters: " + definition.parameters().entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey()).map(entry -> entry.getKey() + "=" + entry.getValue())
|
||||
.collect(java.util.stream.Collectors.joining(", "));
|
||||
String text = definition.name() + "\nIcon: " + definition.icon().name()
|
||||
+ "\n\n" + String.join("\n", definition.lore()) + "\n\n" + definition.instructions()
|
||||
+ "\nRequires: " + requirements + "\nCost: " + definition.pointCost()
|
||||
+ "\nCooldown: " + durationSeconds(definition.cooldown()) + "s" + parameters + "\n\n" + state;
|
||||
var pages = new java.util.ArrayList<>(BookPagination.pages(text));
|
||||
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 ability\n\nSlot:\n");
|
||||
for (int slot = 0; slot < 7; slot++) {
|
||||
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));
|
||||
.collect(java.util.stream.Collectors.joining(", ")));
|
||||
builder.text(state);
|
||||
return builder.pages();
|
||||
}
|
||||
|
||||
private static final class PageBuilder {
|
||||
private final java.util.List<Component> lines = new java.util.ArrayList<>();
|
||||
void blank() { lines.add(Component.empty()); }
|
||||
void text(String text) { BookPagination.lines(text).forEach(line -> lines.add(Component.text(line))); }
|
||||
void component(Component component) { lines.add(component); }
|
||||
void inline(String text, Component action) {
|
||||
var wrapped = BookPagination.lines(text);
|
||||
for (int index = 0; index < wrapped.size() - 1; index++) lines.add(Component.text(wrapped.get(index)));
|
||||
var last = Component.text(wrapped.getLast());
|
||||
if (action != null && displayWidth(wrapped.getLast()) + 6 + 48 <= 108) {
|
||||
lines.add(last.append(Component.space()).append(action));
|
||||
} else {
|
||||
lines.add(last);
|
||||
if (action != null) lines.add(action);
|
||||
}
|
||||
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);
|
||||
void link(String prefix, String label, int page) {
|
||||
if (displayWidth(prefix + label) <= 108) {
|
||||
lines.add(Component.text(prefix).append(Component.text(label, net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.changePage(page))));
|
||||
return;
|
||||
}
|
||||
for (var part : BookPagination.lines(prefix + label)) {
|
||||
lines.add(Component.text(part, net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.changePage(page)));
|
||||
}
|
||||
}
|
||||
private static int displayWidth(String text) {
|
||||
return text.codePoints().map(codePoint -> codePoint < 128 ? 6 : 9).sum();
|
||||
}
|
||||
java.util.List<Component> pages() {
|
||||
var pages = new java.util.ArrayList<Component>();
|
||||
for (int start = 0; start < lines.size(); start += 13) {
|
||||
Component page = Component.empty();
|
||||
for (int index = start; index < Math.min(start + 13, lines.size()); index++) {
|
||||
if (index > start) page = page.append(Component.newline());
|
||||
page = page.append(lines.get(index));
|
||||
}
|
||||
pages.add(page);
|
||||
}
|
||||
if (pages.isEmpty()) pages.add(Component.empty());
|
||||
return java.util.List.copyOf(pages);
|
||||
}
|
||||
}
|
||||
|
||||
private static String durationSeconds(java.time.Duration duration) {
|
||||
@@ -197,8 +301,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
}
|
||||
|
||||
private static Component abilityButton(String label, net.kyori.adventure.key.Key action, AbilityView view,
|
||||
String ability, Integer slot) {
|
||||
String payload = "\"" + view.nonce() + ":" + ability + (slot == null ? "" : ":" + slot) + "\"";
|
||||
String ability) {
|
||||
String payload = "\"" + view.nonce() + ":" + ability + "\"";
|
||||
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(action,
|
||||
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder(payload)));
|
||||
@@ -292,9 +396,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
@org.bukkit.event.EventHandler
|
||||
public void abilityAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
|
||||
boolean unlock = ABILITY_UNLOCK.equals(event.getIdentifier());
|
||||
boolean assign = ABILITY_ASSIGN.equals(event.getIdentifier());
|
||||
boolean unlearn = ABILITY_UNLEARN.equals(event.getIdentifier());
|
||||
if (closed || abilityEngine == null || !unlock && !assign && !unlearn
|
||||
if (closed || abilityEngine == null || !unlock && !unlearn
|
||||
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|
||||
|| event.getTag() == null) return;
|
||||
var player = connection.getPlayer();
|
||||
@@ -304,12 +407,10 @@ 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))
|
||||
|| 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());
|
||||
: abilityEngine.unlearn(view.input(), ability, view.revision());
|
||||
operation.whenComplete((mutation, failure) -> {
|
||||
if (closed || !view.input().stillCurrent()) return;
|
||||
if (failure != null) {
|
||||
@@ -317,22 +418,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, unlearn, ability, mutation.status()));
|
||||
player.sendMessage(abilityMessage(unlock, ability, mutation.status()));
|
||||
load(view.input(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
private static String abilityMessage(boolean unlock, boolean unlearn, String ability,
|
||||
private static String abilityMessage(boolean unlock, String ability,
|
||||
CharacterStorage.AbilityMutationStatus status) {
|
||||
return switch (status) {
|
||||
case APPLIED -> unlock ? "Learned " + ability + "."
|
||||
: unlearn ? "Unlearned " + ability + " and refunded 1 point." : "Assigned " + ability + ".";
|
||||
case UNCHANGED -> ability + " is already assigned there.";
|
||||
case APPLIED -> unlock ? "Learned " + ability + " and assigned it to the first available slot."
|
||||
: "Unlearned " + ability + " and refunded 1 point.";
|
||||
case UNCHANGED -> ability + " is unchanged.";
|
||||
case REQUIREMENTS -> "The purchased base-stat requirements are not met.";
|
||||
case NO_POINTS -> "This character does not have enough unspent points.";
|
||||
case LOADOUT_FULL -> "All seven ability slots are occupied; unlearn an ability before learning another.";
|
||||
case ALREADY_LEARNED -> "This character already knows " + ability + ".";
|
||||
case NOT_LEARNED -> unlearn ? "This character no longer knows " + ability + "."
|
||||
: "Learn " + ability + " before assigning it.";
|
||||
case NOT_LEARNED -> "This character no longer knows " + ability + ".";
|
||||
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.";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package games.dmg.fantasystick.items;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import java.time.Clock;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
@@ -18,6 +19,7 @@ import org.bukkit.persistence.PersistentDataType;
|
||||
*/
|
||||
public final class SessionControls {
|
||||
private final games.dmg.fantasystick.ability.AbilityRegistry registry;
|
||||
private final Clock clock;
|
||||
private static final NamespacedKey VERSION = key("control_version");
|
||||
private static final NamespacedKey OWNER = key("control_owner_id");
|
||||
private static final NamespacedKey SESSION = key("control_session_id");
|
||||
@@ -52,9 +54,13 @@ public final class SessionControls {
|
||||
|
||||
public record Identity(UUID owner, UUID session, int slot) { }
|
||||
|
||||
public SessionControls() { this(new games.dmg.fantasystick.ability.AbilityRegistry()); }
|
||||
public SessionControls() { this(new games.dmg.fantasystick.ability.AbilityRegistry(), Clock.systemUTC()); }
|
||||
public SessionControls(games.dmg.fantasystick.ability.AbilityRegistry registry) {
|
||||
this(registry, Clock.systemUTC());
|
||||
}
|
||||
public SessionControls(games.dmg.fantasystick.ability.AbilityRegistry registry, Clock clock) {
|
||||
this.registry = Objects.requireNonNull(registry);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
private Icon icon(String ability) {
|
||||
@@ -71,13 +77,17 @@ public final class SessionControls {
|
||||
Objects.requireNonNull(owner);
|
||||
Objects.requireNonNull(session);
|
||||
var items = new ItemStack[9];
|
||||
var now = clock.instant();
|
||||
for (int slot = 0; slot < items.length; slot++) {
|
||||
String ability = slot < 2 ? "" : character.loadout().get(slot - 2);
|
||||
Icon abilityIcon = ability.isEmpty() ? null : icon(ability);
|
||||
var deadline = ability.isEmpty() ? null : character.cooldowns().get(ability);
|
||||
boolean cooling = abilityIcon != null && deadline != null && now.isBefore(deadline);
|
||||
Icon icon = slot == 0 ? new Icon(Material.BOOK, "Character book")
|
||||
: slot == 1 ? new Icon(Material.STICK, "Basic attack")
|
||||
: ability.isEmpty() ? new Icon(Material.GRAY_DYE, "No Ability")
|
||||
: abilityIcon == null ? new Icon(Material.PAPER, "Unavailable ability: " + ability) : abilityIcon;
|
||||
: abilityIcon == null ? new Icon(Material.PAPER, "Unavailable ability: " + ability)
|
||||
: cooling ? new Icon(Material.GRAY_STAINED_GLASS_PANE, abilityIcon.name() + " — Cooling Down") : abilityIcon;
|
||||
var item = new ItemStack(icon.material());
|
||||
var meta = item.getItemMeta();
|
||||
meta.displayName(Component.text(icon.name()));
|
||||
@@ -88,7 +98,7 @@ public final class SessionControls {
|
||||
data.set(SESSION, PersistentDataType.STRING, session.toString());
|
||||
data.set(SLOT, PersistentDataType.INTEGER, slot);
|
||||
data.set(KIND, PersistentDataType.STRING, slot == 0 ? "book" : slot == 1 ? "basic_attack"
|
||||
: ability.isEmpty() ? "empty" : abilityIcon != null ? "ability" : "unavailable");
|
||||
: ability.isEmpty() ? "empty" : abilityIcon == null ? "unavailable" : cooling ? "cooldown" : "ability");
|
||||
if (!ability.isEmpty()) data.set(ABILITY, PersistentDataType.STRING, ability);
|
||||
item.setItemMeta(meta);
|
||||
items[slot] = item;
|
||||
@@ -123,7 +133,8 @@ public final class SessionControls {
|
||||
public Optional<String> ability(ItemStack item) {
|
||||
if (identify(item).isEmpty()) return Optional.empty();
|
||||
var data = item.getPersistentDataContainer();
|
||||
return "ability".equals(data.get(KIND, PersistentDataType.STRING))
|
||||
String kind = data.get(KIND, PersistentDataType.STRING);
|
||||
return kind.equals("ability") || kind.equals("cooldown")
|
||||
? Optional.of(data.get(ABILITY, PersistentDataType.STRING)) : Optional.empty();
|
||||
}
|
||||
|
||||
@@ -137,6 +148,7 @@ public final class SessionControls {
|
||||
if (ability.isBlank()) return false;
|
||||
Icon icon = icon(ability);
|
||||
return kind.equals("ability") ? icon != null && icon.material() == material
|
||||
: kind.equals("cooldown") ? icon != null && material == Material.GRAY_STAINED_GLASS_PANE
|
||||
: kind.equals("unavailable") && icon == null && material == Material.PAPER;
|
||||
}
|
||||
|
||||
|
||||
@@ -295,7 +295,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
}
|
||||
|
||||
public enum AbilityMutationStatus {
|
||||
APPLIED, UNCHANGED, STALE, REQUIREMENTS, NO_POINTS, ALREADY_LEARNED, NOT_LEARNED, COOLDOWN, UNAVAILABLE
|
||||
APPLIED, UNCHANGED, STALE, REQUIREMENTS, NO_POINTS, LOADOUT_FULL, ALREADY_LEARNED, NOT_LEARNED, COOLDOWN, UNAVAILABLE
|
||||
}
|
||||
public record AbilityMutation(AbilityMutationStatus status, Optional<CharacterRecord> record) { }
|
||||
|
||||
@@ -317,9 +317,12 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
return new AbilityMutation(AbilityMutationStatus.REQUIREMENTS, Optional.of(old));
|
||||
}
|
||||
if (old.unspentPoints().compareTo(cost) < 0) return new AbilityMutation(AbilityMutationStatus.NO_POINTS, Optional.of(old));
|
||||
int slot = old.loadout().indexOf("");
|
||||
if (slot < 0) return new AbilityMutation(AbilityMutationStatus.LOADOUT_FULL, Optional.of(old));
|
||||
var learned = new java.util.HashSet<>(old.learned()); learned.add(ability);
|
||||
var loadout = new java.util.ArrayList<>(old.loadout()); loadout.set(slot, ability);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints().subtract(cost), old.health(), learned, old.loadout(), old.cooldowns(),
|
||||
old.experience(), old.unspentPoints().subtract(cost), 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));
|
||||
|
||||
@@ -424,12 +424,11 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
|
||||
private void emit(Player player, ItemStack item, EquipmentSessions.SelectedControl selected,
|
||||
ControlInput.Gesture gesture, org.bukkit.entity.Entity target, int heldTicks, float strength) {
|
||||
var captured = item.clone();
|
||||
var kind = selected.slot() == 0 ? ControlInput.Kind.BOOK
|
||||
: selected.slot() == 1 ? ControlInput.Kind.BASIC_ATTACK : ControlInput.Kind.ABILITY;
|
||||
var input = new ControlInput(player, selected.session(), selected.slot(), selected.ability(), kind,
|
||||
gesture, target, heldTicks, strength,
|
||||
() -> !closed && sessions.selectedControl(player, captured).filter(selected::equals).isPresent());
|
||||
() -> !closed && sessions.currentControl(player, selected));
|
||||
if (!input.stillCurrent()) return;
|
||||
try { inputs.accept(input); }
|
||||
catch (RuntimeException failure) { report(player.getUniqueId(), selected.session().session(), failure); }
|
||||
|
||||
@@ -49,11 +49,13 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private games.dmg.fantasystick.presentation.SessionPresentation presentation = games.dmg.fantasystick.presentation.SessionPresentation.NONE;
|
||||
private final SessionClaims claims = new SessionClaims();
|
||||
private final EquipmentStorage equipment = new EquipmentStorage();
|
||||
private SessionControls controls = new SessionControls();
|
||||
private SessionControls controls;
|
||||
private final CharacterSheets sheets = new CharacterSheets();
|
||||
private final Map<UUID, Attempt> pending = new HashMap<>();
|
||||
private final Map<UUID, Attempt> owned = new HashMap<>();
|
||||
private final Map<UUID, Attempt> active = new HashMap<>();
|
||||
private final java.util.ArrayDeque<UUID> cooldownRefreshOrder = new java.util.ArrayDeque<>();
|
||||
private final java.util.Set<UUID> cooldownRefreshQueued = new java.util.HashSet<>();
|
||||
// Covers reentrant inventory setters and client sync, not just asynchronous work.
|
||||
private final Map<UUID, Attempt> transitioning = new HashMap<>();
|
||||
private Consumer<UUID> cursorCustody = ignored -> { };
|
||||
@@ -82,6 +84,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
boolean effectsStarted;
|
||||
String previousGameMode;
|
||||
boolean modeStarted;
|
||||
Instant nextCooldownVisualChange;
|
||||
games.dmg.fantasystick.persistence.OrdinaryVitals ordinaryVitals;
|
||||
boolean vitalsStarted;
|
||||
boolean healthSaveRequested;
|
||||
@@ -125,6 +128,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
var initialSwitchDelay = Objects.requireNonNull(switchDelay);
|
||||
this.switchDelay = () -> initialSwitchDelay;
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.controls = new SessionControls(new games.dmg.fantasystick.ability.AbilityRegistry(), clock);
|
||||
if (switchDelay.isNegative()) throw new IllegalArgumentException("Negative switch delay");
|
||||
this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.notices = Objects.requireNonNull(notices);
|
||||
@@ -163,7 +167,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
/** Install before exposing entry so registered definitions drive delivered control metadata. */
|
||||
public synchronized void attachAbilityRegistry(games.dmg.fantasystick.ability.AbilityRegistry registry) {
|
||||
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
|
||||
controls = new SessionControls(Objects.requireNonNull(registry));
|
||||
controls = new SessionControls(Objects.requireNonNull(registry), clock);
|
||||
}
|
||||
|
||||
/** Install before exposing entry; equipment-only adapter tests can omit the health overlay. */
|
||||
@@ -259,18 +263,70 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
var previous = attempt.record;
|
||||
if (vitals != null) vitals.updateProgress(attempt.player, attempt.session, previous, saved);
|
||||
attempt.record = saved; // Persisted truth precedes any reentrant native presentation setter.
|
||||
if (!previous.loadout().equals(saved.loadout())) {
|
||||
var refreshed = controls.create(saved, attempt.owner, attempt.session);
|
||||
for (int slot = 2; slot < 9; slot++) {
|
||||
if (active.get(attempt.owner) != attempt || !current(session)) return false;
|
||||
attempt.player.getInventory().setItem(slot, refreshed[slot]);
|
||||
}
|
||||
attempt.player.updateInventory(); // No planned native writes after this reentrant boundary.
|
||||
}
|
||||
if ((!previous.loadout().equals(saved.loadout()) || !previous.cooldowns().equals(saved.cooldowns()))
|
||||
&& !refreshControls(attempt, session)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Bounded server-tick refresh of active cooldown controls; persisted deadlines are not mutated. */
|
||||
public synchronized void refreshCooldownControls(int maximumSessions) {
|
||||
if (maximumSessions <= 0) throw new IllegalArgumentException("Cooldown refresh bound must be positive");
|
||||
int checks = Math.min(maximumSessions, cooldownRefreshOrder.size());
|
||||
for (int checked = 0; checked < checks; checked++) {
|
||||
UUID owner = cooldownRefreshOrder.removeFirst();
|
||||
var attempt = active.get(owner);
|
||||
if (attempt == null || attempt.record == null || attempt.session == null) {
|
||||
cooldownRefreshQueued.remove(owner);
|
||||
continue;
|
||||
}
|
||||
cooldownRefreshOrder.addLast(owner);
|
||||
if (attempt.nextCooldownVisualChange != null && !clock.instant().isBefore(attempt.nextCooldownVisualChange)) {
|
||||
refreshControls(attempt, new Active(attempt.owner, attempt.session, attempt.character));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void queueCooldownRefresh(Attempt attempt) {
|
||||
if (cooldownRefreshQueued.add(attempt.owner)) cooldownRefreshOrder.addLast(attempt.owner);
|
||||
}
|
||||
|
||||
private boolean refreshControls(Attempt attempt, Active session) {
|
||||
var refreshed = controls.create(attempt.record, attempt.owner, attempt.session);
|
||||
boolean changed = false;
|
||||
for (int slot = 2; slot < 9; slot++) {
|
||||
if (active.get(attempt.owner) != attempt || !current(session)) return false;
|
||||
if (Objects.equals(attempt.player.getInventory().getItem(slot), refreshed[slot])) continue;
|
||||
attempt.player.getInventory().setItem(slot, refreshed[slot]);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) attempt.player.updateInventory(); // No planned native writes after this reentrant boundary.
|
||||
if (active.get(attempt.owner) != attempt || !current(session)) return false;
|
||||
attempt.nextCooldownVisualChange = nextCooldownVisualChange(attempt.record);
|
||||
return true;
|
||||
}
|
||||
|
||||
private Instant nextCooldownVisualChange(CharacterRecord record) {
|
||||
var now = clock.instant();
|
||||
return record.loadout().stream().filter(ability -> !ability.isEmpty())
|
||||
.map(record.cooldowns()::get).filter(Objects::nonNull).filter(deadline -> now.isBefore(deadline))
|
||||
.min(Instant::compareTo).orElse(null);
|
||||
}
|
||||
|
||||
/** Server-thread authorization for the currently held, issued control and saved loadout. */
|
||||
public synchronized boolean currentControl(Player player, SelectedControl selected) {
|
||||
if (selected == null) return false;
|
||||
var attempt = active.get(player.getUniqueId());
|
||||
if (closed || attempt == null || attempt.player != player || attempt.record == null
|
||||
|| !current(selected.session()) || selected.slot() != player.getInventory().getHeldItemSlot()) return false;
|
||||
var actual = player.getInventory().getItem(selected.slot());
|
||||
var identity = controls.identify(actual).orElse(null);
|
||||
if (identity == null || identity.slot() != selected.slot() || !identity.owner().equals(attempt.owner)
|
||||
|| !identity.session().equals(attempt.session)) return false;
|
||||
String ability = selected.slot() < 2 ? "" : controls.ability(actual).orElse("");
|
||||
return ability.equals(selected.ability()) && (selected.slot() < 2
|
||||
|| ability.equals(attempt.record.loadout().get(selected.slot() - 2)));
|
||||
}
|
||||
|
||||
public synchronized Optional<SelectedControl> selectedControl(Player player, ItemStack reported) {
|
||||
var attempt = active.get(player.getUniqueId());
|
||||
if (closed || attempt == null || attempt.player != player || !player.isOnline() || attempt.record == null
|
||||
@@ -456,7 +512,9 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
pending.remove(attempt.owner, attempt);
|
||||
if (!attempt.exitRequested) {
|
||||
attempt.record = record;
|
||||
attempt.nextCooldownVisualChange = nextCooldownVisualChange(record);
|
||||
active.put(attempt.owner, attempt);
|
||||
queueCooldownRefresh(attempt);
|
||||
attempt.presentationInstalled = true;
|
||||
try { presentation.activate(attempt.player, attempt.session, record); }
|
||||
catch (RuntimeException | LinkageError error) { withdrawPresentation(attempt); diagnostic(attempt, error); }
|
||||
|
||||
@@ -146,8 +146,8 @@ class AbilityEngineNativeTest {
|
||||
String available = PartyBookNativeTest.text(a);
|
||||
String compactAvailable = available.replaceAll("\\s+", "");
|
||||
assertTrue(compactAvailable.contains("TestFocus"), available);
|
||||
assertTrue(compactAvailable.contains("AMETHYST_SHARD"), available);
|
||||
assertTrue(compactAvailable.contains("Cost:1"), available);
|
||||
assertFalse(compactAvailable.contains("Icon:"), available);
|
||||
assertFalse(compactAvailable.contains("Cost:"), available);
|
||||
assertTrue(compactAvailable.contains("Cooldown:4s"), available);
|
||||
var learn = PartyBookNativeTest.button(a, "[Learn]");
|
||||
PartyBookNativeTest.click(f, a, learn); PartyBookNativeTest.click(f, a, learn);
|
||||
@@ -157,10 +157,7 @@ class AbilityEngineNativeTest {
|
||||
assertEquals(java.util.Set.of("test_focus"), learned.learned());
|
||||
assertEquals(BigInteger.valueOf(39), learned.unspentPoints(), "Repeated stale book action cannot spend twice");
|
||||
|
||||
var assign = PartyBookNativeTest.button(a, "[3]");
|
||||
PartyBookNativeTest.click(f, a, assign); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var assigned = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals("test_focus", assigned.loadout().getFirst());
|
||||
assertEquals("test_focus", learned.loadout().getFirst());
|
||||
var control = a.core.inventory.getItem(2);
|
||||
assertEquals(Material.AMETHYST_SHARD, control.getType());
|
||||
assertEquals("test_focus", new games.dmg.fantasystick.items.SessionControls(registry).ability(control).orElseThrow());
|
||||
@@ -173,9 +170,15 @@ class AbilityEngineNativeTest {
|
||||
assertEquals(1, effects.get());
|
||||
var cooled = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertTrue(cooled.cooldowns().get("test_focus").isAfter(java.time.Instant.now()));
|
||||
var cooling = a.core.inventory.getItem(2);
|
||||
assertEquals(Material.GRAY_STAINED_GLASS_PANE, cooling.getType());
|
||||
assertEquals(net.kyori.adventure.text.Component.text("Test Focus — Cooling Down"), cooling.getItemMeta().displayName());
|
||||
assertEquals("test_focus", new games.dmg.fantasystick.items.SessionControls(registry).ability(cooling).orElseThrow());
|
||||
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR,
|
||||
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
|
||||
var repeated = new org.bukkit.event.player.PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR,
|
||||
cooling, null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND);
|
||||
f.dispatcher.accept(repeated);
|
||||
assertEquals(org.bukkit.event.Event.Result.DENY, repeated.useItemInHand(), "The pane remains a contained control");
|
||||
PartyBookNativeTest.drain(f);
|
||||
assertEquals(1, effects.get(), "The persisted character deadline refuses an immediate repeated cast");
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
@@ -185,6 +188,7 @@ class AbilityEngineNativeTest {
|
||||
PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
assertEquals(Material.GRAY_STAINED_GLASS_PANE, a.core.inventory.getItem(2).getType());
|
||||
assertEquals("test_focus", new games.dmg.fantasystick.items.SessionControls(registry)
|
||||
.ability(a.core.inventory.getItem(2)).orElseThrow());
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR,
|
||||
|
||||
@@ -38,7 +38,6 @@ class ChainLightningNativeTest {
|
||||
String text = PartyBookNativeTest.text(caster).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("ChainLightning")); assertTrue(text.contains("INT20")); assertTrue(text.contains("DEX12"));
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "chain_lightning")); drain(fixture, 2);
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[3]", "chain_lightning")); drain(fixture, 2);
|
||||
var first = mock(LivingEntity.class); var second = mock(LivingEntity.class);
|
||||
locate(first, caster.world, UUID.randomUUID(), 10); locate(second, caster.world, UUID.randomUUID(), 14);
|
||||
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getLocation();
|
||||
|
||||
@@ -72,7 +72,7 @@ class CharacterBookNativeTest {
|
||||
assertEquals(1, a.books.size());
|
||||
String rendered = text(a.books.getFirst()).replace("\n", "");
|
||||
for (String expected : java.util.List.of("Level: 30", "XP: MAX", "Unspent points: 9", "STR: " + strength,
|
||||
"revive", "unavailable_saved_id")) assertTrue(rendered.contains(expected), expected);
|
||||
"Revive", "unavailable_saved_id")) assertTrue(rendered.contains(expected), expected);
|
||||
assertEquals(saved, characters(f).find(old.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
}
|
||||
}
|
||||
@@ -218,7 +218,7 @@ class CharacterBookNativeTest {
|
||||
var stats = new java.util.EnumMap<games.dmg.fantasystick.progression.Stat, java.math.BigInteger>(games.dmg.fantasystick.progression.Stat.class);
|
||||
stats.putAll(old.stats()); stats.put(games.dmg.fantasystick.progression.Stat.STR, new java.math.BigInteger(digits));
|
||||
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
|
||||
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(), false, null, null);
|
||||
stats, old.level(), old.experience(), java.math.BigInteger.ZERO, old.health(), old.learned(), old.loadout(), old.cooldowns(), false, null, null);
|
||||
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(saved, old.revision());
|
||||
byte[] bytes = Files.readAllBytes(data.resolve("characters").resolve(saved.id() + ".yml"));
|
||||
click(f, a); drainReads(f); runQueued(f);
|
||||
@@ -307,6 +307,123 @@ class CharacterBookNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void linkedBookOrdersOverviewActiveIndexAndConciseAbilityDetailsWithAutomaticAssignment() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(a.core.record.id()).orElseThrow();
|
||||
var stats = new java.util.EnumMap<>(old.stats());
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.INT, java.math.BigInteger.valueOf(12));
|
||||
var eligible = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
|
||||
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null);
|
||||
repository.replace(eligible, old.revision());
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
PartyBookNativeTest.open(f, a);
|
||||
|
||||
var pages = nativePages(a);
|
||||
assertTrue(pages.size() <= 100, "Keep each native linked-book view bounded");
|
||||
assertTrue(pages.get(0).getString().contains("STR: 0 [+1 STR]"));
|
||||
assertTrue(pages.get(0).getString().contains("INT: 12 [+1 INT]"));
|
||||
assertTrue(pages.get(1).getString().startsWith("Active abilities"));
|
||||
assertTrue(pages.get(1).getString().contains("3: No Ability"));
|
||||
int index = firstPageStarting(pages, "Ability index");
|
||||
assertTrue(index > 1);
|
||||
var indexedNames = new java.util.ArrayList<String>();
|
||||
int partyPage = firstPageStarting(pages, "Party");
|
||||
for (int page = index; page < pages.size() && pages.get(page).getString().startsWith("Ability index"); page++) {
|
||||
for (String line : pages.get(page).getString().split("\\n")) if (!line.isBlank() && !line.equals("Ability index")) {
|
||||
indexedNames.add(line);
|
||||
var jump = changePage(pages.get(page), line);
|
||||
assertNotNull(jump, line);
|
||||
assertTrue(jump.page() - 1 < partyPage, "Ability details must precede party controls");
|
||||
}
|
||||
}
|
||||
var sortedNames = indexedNames.stream().sorted().toList();
|
||||
assertEquals(sortedNames, indexedNames);
|
||||
assertEquals(20, indexedNames.size());
|
||||
var fireballJump = changePage(pages.get(index), "Fireball");
|
||||
assertNotNull(fireballJump);
|
||||
var detail = pages.get(fireballJump.page() - 1);
|
||||
assertTrue(detail.getString().startsWith("Fireball\nINT 12\n[Learn]"), detail.getString());
|
||||
assertFalse(text(a.books.getLast()).contains("Cost:"));
|
||||
assertFalse(text(a.books.getLast()).contains("Requires:"));
|
||||
assertTrue(text(a.books.getLast()).contains("Shield Bash\nSTR 12, CON 12"));
|
||||
assertFalse(detail.getString().contains("Icon:"));
|
||||
assertFalse(text(a.books.getLast()).contains("Assign ability"));
|
||||
assertFalse(text(a.books.getLast()).contains("[3]"));
|
||||
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Learn]"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals("fireball", saved.loadout().get(0));
|
||||
var refreshed = nativePages(a);
|
||||
assertTrue(refreshed.get(1).getString().contains("3: Fireball"));
|
||||
var activeJump = changePage(refreshed.get(1), "Fireball");
|
||||
assertNotNull(activeJump);
|
||||
assertTrue(refreshed.get(activeJump.page() - 1).getString().startsWith("Fireball\nINT 12\n[Unlearn]"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unlearnAndRelearnReordersIntoLowestOpenSlotsWhileFullLoadoutsRefuse() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(a.core.record.id()).orElseThrow();
|
||||
var stats = new java.util.EnumMap<>(old.stats());
|
||||
for (var stat : games.dmg.fantasystick.progression.Stat.values()) stats.put(stat, java.math.BigInteger.valueOf(100));
|
||||
var prepared = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
|
||||
stats, old.level(), old.experience(), java.math.BigInteger.TEN, old.health(),
|
||||
java.util.Set.of("unknown_saved", "orphan_unknown"), java.util.List.of("unknown_saved", "", "", "", "", "", ""),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null);
|
||||
repository.replace(prepared, old.revision());
|
||||
f.players.put(a.core.id, a.player); f.activate(a); PartyBookNativeTest.open(f, a);
|
||||
assertTrue(PartyBookNativeTest.text(a).contains("orphan_unknown"));
|
||||
|
||||
mutate(f, a, "[Learn]", "fireball");
|
||||
mutate(f, a, "[Learn]", "frost_shard");
|
||||
mutate(f, a, "[Unlearn]", "fireball");
|
||||
mutate(f, a, "[Unlearn]", "frost_shard");
|
||||
mutate(f, a, "[Learn]", "frost_shard");
|
||||
mutate(f, a, "[Learn]", "fireball");
|
||||
var reordered = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals(java.util.List.of("unknown_saved", "frost_shard", "fireball", "", "", "", ""), reordered.loadout());
|
||||
assertTrue(reordered.learned().contains("orphan_unknown"));
|
||||
|
||||
var fullActor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var fullOld = repository.load(fullActor.core.record.id()).orElseThrow();
|
||||
var fullStats = new java.util.EnumMap<>(fullOld.stats());
|
||||
for (var stat : games.dmg.fantasystick.progression.Stat.values()) fullStats.put(stat, java.math.BigInteger.valueOf(100));
|
||||
var ids = java.util.List.of("unknown_0", "unknown_1", "unknown_2", "unknown_3", "unknown_4", "unknown_5", "unknown_6");
|
||||
var full = new games.dmg.fantasystick.persistence.CharacterRecord(fullOld.id(), fullOld.name(), fullOld.revision().add(java.math.BigInteger.ONE),
|
||||
fullStats, fullOld.level(), fullOld.experience(), java.math.BigInteger.TEN, fullOld.health(), java.util.Set.copyOf(ids), ids,
|
||||
fullOld.cooldowns(), fullOld.restrictedKills(), false, null, null);
|
||||
repository.replace(full, fullOld.revision());
|
||||
f.players.put(fullActor.core.id, fullActor.player); f.activate(fullActor); PartyBookNativeTest.open(f, fullActor);
|
||||
assertTrue(PartyBookNativeTest.text(fullActor).contains("Unavailable: slots"));
|
||||
assertNull(abilityButtonOrNull(fullActor, "[Learn]", "fireball"));
|
||||
assertEquals(full, characters(f).find(full.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void overviewHidesEveryStatActionWhenNoPointsRemain() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(a.core.record.id()).orElseThrow();
|
||||
var noPoints = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
|
||||
old.stats(), old.level(), old.experience(), java.math.BigInteger.ZERO, old.health(), old.learned(), old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null);
|
||||
repository.replace(noPoints, old.revision());
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
PartyBookNativeTest.open(f, a);
|
||||
assertFalse(nativePages(a).get(0).getString().contains("[+1"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void bookAdapterIgnoresOtherControlsAndNativeItemVetoes() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
@@ -321,6 +438,51 @@ class CharacterBookNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static void mutate(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) throws Exception {
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButtonOrNull(actor, label, ability));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom abilityButtonOrNull(
|
||||
FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
|
||||
for (var page : nativePages(actor)) {
|
||||
var found = abilityButtonOrNull(page, label, ability);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom abilityButtonOrNull(
|
||||
net.minecraft.network.chat.Component component, String label, String ability) {
|
||||
if (component.getString().equals(label)
|
||||
&& component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom custom
|
||||
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
|
||||
&& payload.value().contains(":" + ability)) return custom;
|
||||
for (var child : component.getSiblings()) {
|
||||
var found = abilityButtonOrNull(child, label, ability);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
private static java.util.List<net.minecraft.network.chat.Component> nativePages(FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
assertNotNull(content);
|
||||
return content.pages().stream().map(page -> page.raw()).toList();
|
||||
}
|
||||
private static int firstPageStarting(java.util.List<net.minecraft.network.chat.Component> pages, String prefix) {
|
||||
for (int index = 0; index < pages.size(); index++) if (pages.get(index).getString().startsWith(prefix)) return index;
|
||||
throw new AssertionError("Missing page " + prefix);
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.ChangePage changePage(net.minecraft.network.chat.Component component,
|
||||
String label) {
|
||||
if (component.getString().equals(label)
|
||||
&& component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.ChangePage page) return page;
|
||||
for (var child : component.getSiblings()) {
|
||||
var found = changePage(child, label);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
private static String text(org.bukkit.inventory.ItemStack book) {
|
||||
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(book)
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
|
||||
@@ -1059,6 +1059,38 @@ class EquipmentSessionsNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acknowledgedCharacterCooldownUsesAnAuthorizedPaneAcrossResumeAndRestoresAtTheExactDeadline() throws Exception {
|
||||
var clock = new MutableClock();
|
||||
try (var f = new Fixture(Duration.ofSeconds(900), clock)) {
|
||||
var actor = f.actor(); f.activate(actor);
|
||||
var active = f.sessions.active(actor.id).orElseThrow();
|
||||
var current = f.sessions.currentRecord(active).orElseThrow();
|
||||
var learnedMutation = f.characters.unlockAbility(current.id(), current.revision(), "fireball", java.util.Map.of(), java.math.BigInteger.ONE)
|
||||
.get(5, TimeUnit.SECONDS);
|
||||
var learned = learnedMutation.record().orElseThrow();
|
||||
assertTrue(f.sessions.acknowledgeProgress(active, current.revision(), learned));
|
||||
assertEquals(Material.SNOWBALL, actor.inventory.getItem(2).getType());
|
||||
|
||||
var deadline = clock.now.plusSeconds(30);
|
||||
var cooldownMutation = f.characters.commitAbilityCooldown(learned.id(), learned.revision(), "fireball", deadline)
|
||||
.get(5, TimeUnit.SECONDS);
|
||||
var cooled = cooldownMutation.record().orElseThrow();
|
||||
assertTrue(f.sessions.acknowledgeProgress(active, learned.revision(), cooled));
|
||||
var pane = actor.inventory.getItem(2).clone();
|
||||
assertEquals(Material.GRAY_STAINED_GLASS_PANE, pane.getType());
|
||||
|
||||
f.sessions.exit(actor.player); f.state(actor); f.activate(actor);
|
||||
assertEquals(Material.GRAY_STAINED_GLASS_PANE, actor.inventory.getItem(2).getType(),
|
||||
"Resume reconstructs the future persisted deadline");
|
||||
clock.advance(Duration.ofSeconds(30));
|
||||
f.sessions.refreshCooldownControls(1);
|
||||
assertEquals(Material.SNOWBALL, actor.inventory.getItem(2).getType());
|
||||
assertNotEquals(pane, actor.inventory.getItem(2), "The retired pane is no longer installed");
|
||||
assertEquals(deadline, f.sessions.currentRecord(f.sessions.active(actor.id).orElseThrow()).orElseThrow()
|
||||
.cooldowns().get("fireball"), "Visual restoration does not rewrite the deadline");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void sameIdFailedEquipmentPreflightPreservesExistingDeadlineAndAllItems() throws Exception {
|
||||
for (boolean full : new boolean[]{false, true}) {
|
||||
var clock = new MutableClock();
|
||||
|
||||
@@ -105,7 +105,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertNotNull(content);
|
||||
String text = content.pages().stream().map(page -> page.raw().getString()).collect(java.util.stream.Collectors.joining("\n"));
|
||||
for (String expected : List.of("Current saved name", "STR: 0", "DEX: 0", "CON: 0", "INT: 0", "WIS: 0", "CHR: 0",
|
||||
"Level: 1", "XP: 0/100", "Unspent points: 40", "Health: 10/10", "Abilities: none")) {
|
||||
"Level: 1", "XP: 0/100", "Unspent points: 40", "Health: 10/10", "Active abilities", "3: No Ability")) {
|
||||
assertTrue(text.contains(expected), expected + " missing from " + text);
|
||||
}
|
||||
assertArrayEquals(original, a.core.snapshot(), "Opening a virtual book never replaces or grants an inventory item");
|
||||
|
||||
@@ -23,8 +23,6 @@ class FireballNativeTest {
|
||||
assertTrue(PartyBookNativeTest.text(a).contains("INT 12"));
|
||||
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");
|
||||
var assigned = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
@@ -109,8 +107,6 @@ class FireballNativeTest {
|
||||
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());
|
||||
@@ -152,8 +148,6 @@ class FireballNativeTest {
|
||||
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")));
|
||||
@@ -163,6 +157,8 @@ class FireballNativeTest {
|
||||
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(org.bukkit.Material.GRAY_STAINED_GLASS_PANE, b.core.inventory.getItem(2).getType(),
|
||||
"The transferred character reconstructs its retained cooldown pane");
|
||||
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());
|
||||
@@ -291,6 +287,8 @@ class FireballNativeTest {
|
||||
games.dmg.fantasystick.session.ControlInput.Gesture.USE, null, 0, 0, () -> true));
|
||||
org.mockito.Mockito.verify(player, org.mockito.Mockito.never()).launchProjectile(
|
||||
org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Snowball.class), org.mockito.ArgumentMatchers.any());
|
||||
org.mockito.Mockito.verify(sessions, org.mockito.Mockito.never()).acknowledgeProgress(
|
||||
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
|
||||
assertEquals(1, failures.size());
|
||||
engine.close(); fireball.close(); parties.close();
|
||||
}
|
||||
|
||||
@@ -74,9 +74,8 @@ class FrostNovaNativeTest {
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
|
||||
String text = PartyBookNativeTest.text(caster).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("FrostNova")); assertTrue(text.contains("INT16")); assertTrue(text.contains("DEX10"));
|
||||
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("5")); assertTrue(text.contains("SlownessII"));
|
||||
assertFalse(text.contains("Cost:")); assertTrue(text.contains("5")); assertTrue(text.contains("SlownessII"));
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "frost_nova")); drain(fixture, 2);
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[3]", "frost_nova")); drain(fixture, 2);
|
||||
use(fixture, caster); drain(fixture, 2);
|
||||
assertEquals(8, damage.get(enemy.getUniqueId()), 1.0e-9); assertEquals(8, damage.get(opponent.core.id), 1.0e-9);
|
||||
assertEquals(2, damage.size(), "Duplicate, cancelled, party and ordinary targets cannot add accepted hits");
|
||||
|
||||
@@ -28,8 +28,6 @@ class FrostShardNativeTest {
|
||||
var learn = abilityButton(a, "[Learn]", "frost_shard");
|
||||
PartyBookNativeTest.click(f, a, learn); PartyBookNativeTest.click(f, a, learn);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "frost_shard"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var assigned = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
@@ -447,8 +445,6 @@ class FrostShardNativeTest {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "frost_shard"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "frost_shard"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
|
||||
private static void cast(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
|
||||
@@ -30,8 +30,6 @@ class GreatSwordNativeTest {
|
||||
var learn = abilityButton(a, "[Learn]", "great_sword");
|
||||
PartyBookNativeTest.click(f, a, learn); PartyBookNativeTest.click(f, a, learn);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "great_sword"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
@@ -317,8 +315,6 @@ class GreatSwordNativeTest {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "great_sword"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "great_sword"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom abilityButton(
|
||||
|
||||
@@ -55,9 +55,8 @@ class GuardianBondNativeTest {
|
||||
guardian.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, guardian);
|
||||
String text = PartyBookNativeTest.text(guardian).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("GuardianBond")); assertTrue(text.contains("CON16")); assertTrue(text.contains("WIS10"));
|
||||
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("30%")); assertTrue(text.contains("6seconds"));
|
||||
assertFalse(text.contains("Cost:")); assertTrue(text.contains("30%")); assertTrue(text.contains("6seconds"));
|
||||
PartyBookNativeTest.click(fixture, guardian, button(guardian, "[Learn]", "guardian_bond")); drain(fixture, 2);
|
||||
PartyBookNativeTest.click(fixture, guardian, button(guardian, "[3]", "guardian_bond")); drain(fixture, 2);
|
||||
use(fixture, guardian); drain(fixture, 2);
|
||||
|
||||
var enemy = mock(org.bukkit.entity.Zombie.class); when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
|
||||
@@ -37,9 +37,8 @@ class HexNativeTest {
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
String book = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
|
||||
assertTrue(book.contains("Hex")); assertTrue(book.contains("CHR14")); assertTrue(book.contains("WIS12"));
|
||||
assertTrue(book.contains("Cost:1")); assertTrue(book.contains("12")); assertTrue(book.contains("20%")); assertTrue(book.contains("4s"));
|
||||
assertFalse(book.contains("Cost:")); assertTrue(book.contains("12")); assertTrue(book.contains("20%")); assertTrue(book.contains("4s"));
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "hex")); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "hex")); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
|
||||
var enemy = mock(org.bukkit.entity.LivingEntity.class); var enemyId = java.util.UUID.randomUUID();
|
||||
when(enemy.getUniqueId()).thenReturn(enemyId); when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
@@ -174,7 +173,7 @@ class HexNativeTest {
|
||||
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("hex"));}}
|
||||
}
|
||||
|
||||
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,abilityButton(a,"[Learn]","hex"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);PartyBookNativeTest.click(f,a,abilityButton(a,"[3]","hex"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
|
||||
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,abilityButton(a,"[Learn]","hex"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
|
||||
|
||||
private static void use(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a){a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND));}
|
||||
|
||||
@@ -2,7 +2,7 @@ package games.dmg.fantasystick;
|
||||
import static org.junit.jupiter.api.Assertions.*;import static org.mockito.Mockito.*;
|
||||
import games.dmg.fantasystick.persistence.*;import games.dmg.fantasystick.progression.Stat;import java.math.BigInteger;import java.nio.file.Path;import java.util.*;import net.minecraft.network.chat.ClickEvent;import org.bukkit.event.player.PlayerInteractEvent;import org.bukkit.inventory.EquipmentSlot;import org.junit.jupiter.api.*;import org.junit.jupiter.api.io.TempDir;
|
||||
class InspireNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void bookAcquisitionSneakUseAddsOwnedTemporaryHealthAndExpiryPreservesUnrelatedAbsorption()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,false);f.players.put(a.core.id,a.player);f.activate(a);a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);String book=PartyBookNativeTest.text(a).replaceAll("\\s+","");assertTrue(book.contains("Inspire"));assertTrue(book.contains("CHR12"));assertTrue(book.contains("WIS10"));assertTrue(book.contains("Cost:1"));assertTrue(book.contains("12"));assertTrue(book.contains("6s"));PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);PartyBookNativeTest.click(f,a,button(a,"[3]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
|
||||
@Test void bookAcquisitionSneakUseAddsOwnedTemporaryHealthAndExpiryPreservesUnrelatedAbsorption()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,false);f.players.put(a.core.id,a.player);f.activate(a);a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);String book=PartyBookNativeTest.text(a).replaceAll("\\s+","");assertTrue(book.contains("Inspire"));assertTrue(book.contains("CHR12"));assertTrue(book.contains("WIS10"));assertFalse(book.contains("Cost:"));assertTrue(book.contains("12"));assertTrue(book.contains("6s"));PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
|
||||
double maximum=a.maximumHealth.getBaseValue();var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(c->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);assertEquals(maximum,a.maximumHealth.getBaseValue(),1e-9);assertEquals(120L,f.delayedTicks.getLast());f.delayed.getLast().run();assertEquals(2,absorption.get(),1e-9);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire"));}}
|
||||
@Test void aimedPartyAllyActualAbsorptionCreatesPersistedSupportShare()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);withInspire(data,a,true);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()));
|
||||
doReturn(false).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();doReturn(true).when(a.player).hasLineOfSight(b.player);doReturn(b.player).when(a.player).getTargetEntity(64);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(c->absorption.get()).when(b.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(b.player).setAbsorptionAmount(anyDouble());use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(5,absorption.get(),1e-9);
|
||||
@@ -20,7 +20,7 @@ class InspireNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()thr
|
||||
var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(c->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(10,absorption.get(),1e-9);assertEquals(160L,f.delayedTicks.getLast());
|
||||
f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","-1"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","9","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reload=(games.dmg.fantasystick.ability.AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new java.math.BigDecimal("9"),reload.find("inspire").orElseThrow().parameters().get("base-temporary-health"));}
|
||||
for(String invalid:List.of("inspire-base-temporary-health: -1\n","inspire-cooldown-seconds: -1\n","inspire-range-blocks: 0\n","inspire-duration-seconds: 0\n")){var dir=data.resolve(Integer.toHexString(invalid.hashCode()));java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),invalid);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty());}}}
|
||||
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);PartyBookNativeTest.click(f,a,button(a,"[3]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
|
||||
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
|
||||
private static void use(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a){a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND));}
|
||||
private static ClickEvent.Custom button(FantasyStickLifecycleNativeTest.Actor a,String label,String ability){var b=org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast()).get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);assertNotNull(b);for(var p:b.pages()){var x=button(p.raw(),label,ability);if(x!=null)return x;}throw new AssertionError("missing action");}
|
||||
private static ClickEvent.Custom button(net.minecraft.network.chat.Component c,String label,String ability){if(c.getString().equals(label)&&c.getStyle().getClickEvent() instanceof ClickEvent.Custom x&&x.payload().orElse(null) instanceof net.minecraft.nbt.StringTag p&&p.value().contains(":"+ability))return x;for(var k:c.getSiblings()){var x=button(k,label,ability);if(x!=null)return x;}return null;}
|
||||
|
||||
@@ -45,12 +45,10 @@ class LongbowNativeTest {
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
String text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("Longbow")); assertTrue(text.contains("DEX14"));
|
||||
assertTrue(text.contains("Cost:1"), text); assertTrue(text.contains("Draw"), text);
|
||||
assertFalse(text.contains("Cost:"), text); assertTrue(text.contains("Draw"), text);
|
||||
assertTrue(text.contains("1s"), text);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "longbow"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "longbow"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var learned = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertTrue(learned.learned().contains("longbow")); assertEquals("longbow", learned.loadout().getFirst());
|
||||
@@ -411,8 +409,6 @@ class LongbowNativeTest {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "longbow"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "longbow"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
|
||||
private static void drawAndRelease(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
|
||||
@@ -58,12 +58,10 @@ class MendingLightNativeTest {
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
|
||||
String text = PartyBookNativeTest.text(caster).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("MendingLight")); assertTrue(text.contains("WIS12"));
|
||||
assertTrue(text.contains("CHR10")); assertTrue(text.contains("Cost:1"));
|
||||
assertTrue(text.contains("CHR10")); assertFalse(text.contains("Cost:"));
|
||||
assertTrue(text.contains("12")); assertTrue(text.contains("8"));
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "mending_light"));
|
||||
drain(fixture, 2);
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[3]", "mending_light"));
|
||||
drain(fixture, 2);
|
||||
|
||||
use(fixture, caster); drain(fixture, 3);
|
||||
var storage = storage(fixture);
|
||||
@@ -272,7 +270,6 @@ class MendingLightNativeTest {
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
PartyBookNativeTest.click(fixture, actor, button(actor, "[Learn]", "mending_light")); drain(fixture, 2);
|
||||
PartyBookNativeTest.click(fixture, actor, button(actor, "[3]", "mending_light")); drain(fixture, 2);
|
||||
}
|
||||
|
||||
private static PartyRoster.Party party(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
|
||||
@@ -30,7 +30,7 @@ class MeteorNativeTest {
|
||||
|
||||
@Test void characterBookPurchaseEquippedGroundWarningAndNativeImpact()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false);f.players.put(caster.core.id,caster.player);f.activate(caster);var placement=placement(caster,24,BlockFace.UP);var enemy=mock(LivingEntity.class);locate(enemy,caster.world,new UUID(0,2),2,65,0);doReturn(List.of(enemy)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());var damaged=new ArrayList<Double>();doAnswer(call->{double amount=call.getArgument(0);damaged.add(amount);var source=(DamageSource)call.getArgument(1);f.dispatcher.accept(new EntityDamageByEntityEvent(caster.player,enemy,EntityDamageEvent.DamageCause.MAGIC,source,amount));return null;}).when(enemy).damage(anyDouble(),any(DamageSource.class));
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Meteor"));assertTrue(text.contains("INT26"));assertTrue(text.contains("WIS20"));assertTrue(text.contains("CON12"));assertTrue(text.contains("2-secondwarning"));PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","meteor"));drain(f,2);PartyBookNativeTest.click(f,caster,button(caster,"[3]","meteor"));drain(f,2);use(f,caster);drain(f,2);assertTrue(damaged.isEmpty());assertEquals(1L,f.delayedTicks.getLast());for(int i=0;i<39;i++)f.delayed.removeFirst().run();assertTrue(damaged.isEmpty(),"The 40-tick warning must remain escapable");f.delayed.removeFirst().run();assertEquals(List.of(22d),damaged);verify(caster.world,atLeast(40)).spawnParticle(eq(Particle.DUST),any(Location.class),eq(24),anyDouble(),anyDouble(),anyDouble(),eq(0d),any(Particle.DustOptions.class));verify(caster.world,atLeastOnce()).spawnParticle(eq(Particle.EXPLOSION_EMITTER),any(Location.class),eq(2),anyDouble(),anyDouble(),anyDouble(),anyDouble());verify(placement,never()).setType(any(Material.class));assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("meteor"));assertEquals(BigInteger.ZERO,storage(f).find(caster.core.record.id()).get().orElseThrow().unspentPoints());
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Meteor"));assertTrue(text.contains("INT26"));assertTrue(text.contains("WIS20"));assertTrue(text.contains("CON12"));assertTrue(text.contains("2-secondwarning"));PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","meteor"));drain(f,2);use(f,caster);drain(f,2);assertTrue(damaged.isEmpty());assertEquals(1L,f.delayedTicks.getLast());for(int i=0;i<39;i++)f.delayed.removeFirst().run();assertTrue(damaged.isEmpty(),"The 40-tick warning must remain escapable");f.delayed.removeFirst().run();assertEquals(List.of(22d),damaged);verify(caster.world,atLeast(40)).spawnParticle(eq(Particle.DUST),any(Location.class),eq(24),anyDouble(),anyDouble(),anyDouble(),eq(0d),any(Particle.DustOptions.class));verify(caster.world,atLeastOnce()).spawnParticle(eq(Particle.EXPLOSION_EMITTER),any(Location.class),eq(2),anyDouble(),anyDouble(),anyDouble(),anyDouble());verify(placement,never()).setType(any(Material.class));assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("meteor"));assertEquals(BigInteger.ZERO,storage(f).find(caster.core.record.id()).get().orElseThrow().unspentPoints());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,9 +56,8 @@ class PurifyNativeTest {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
String book = PartyBookNativeTest.text(actor).replaceAll("\\s+", "");
|
||||
assertTrue(book.contains("Purify")); assertTrue(book.contains("WIS14")); assertTrue(book.contains("CHR10"));
|
||||
assertTrue(book.contains("Cost:1")); assertTrue(book.contains("15")); assertTrue(book.contains("12"));
|
||||
assertFalse(book.contains("Cost:")); assertTrue(book.contains("15")); assertTrue(book.contains("12"));
|
||||
PartyBookNativeTest.click(fixture, actor, button(actor, "[Learn]", "purify")); drain(fixture, 2);
|
||||
PartyBookNativeTest.click(fixture, actor, button(actor, "[3]", "purify")); drain(fixture, 2);
|
||||
doReturn(true).when(actor.player).isSneaking(); use(fixture, actor); drain(fixture, 2);
|
||||
|
||||
harmful.forEach(type -> assertFalse(effects.containsKey(type), type + " and its hidden chain must be gone"));
|
||||
@@ -249,7 +248,6 @@ class PurifyNativeTest {
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
PartyBookNativeTest.click(fixture, actor, button(actor, "[Learn]", "purify")); drain(fixture, 2);
|
||||
PartyBookNativeTest.click(fixture, actor, button(actor, "[3]", "purify")); drain(fixture, 2);
|
||||
}
|
||||
|
||||
private static void party(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
|
||||
@@ -34,11 +34,9 @@ class QuickstepNativeTest {
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
String text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("Quickstep")); assertTrue(text.contains("DEX12"));
|
||||
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("4")); assertTrue(text.contains("5s"));
|
||||
assertFalse(text.contains("Cost:")); assertTrue(text.contains("4")); assertTrue(text.contains("5s"));
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "quickstep"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "quickstep"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertEquals(BigInteger.valueOf(39), storage.find(a.core.record.id()).get().orElseThrow().unspentPoints());
|
||||
|
||||
@@ -247,8 +245,6 @@ class QuickstepNativeTest {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "quickstep"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "quickstep"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
|
||||
private static AtomicReference<Vector> prepareMovement(FantasyStickLifecycleNativeTest.Actor actor, Vector initial) {
|
||||
|
||||
@@ -55,9 +55,8 @@ class RallyingCryNativeTest {
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
|
||||
String text = PartyBookNativeTest.text(caster).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("RallyingCry")); assertTrue(text.contains("CHR16")); assertTrue(text.contains("CON12"));
|
||||
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("20%")); assertTrue(text.contains("6seconds"));
|
||||
assertFalse(text.contains("Cost:")); assertTrue(text.contains("20%")); assertTrue(text.contains("6seconds"));
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "rallying_cry")); drain(fixture, 2);
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[3]", "rallying_cry")); drain(fixture, 2);
|
||||
use(fixture, caster); drain(fixture, 2);
|
||||
assertEquals(0, casterEffects.get(PotionEffectType.SPEED).getAmplifier());
|
||||
assertEquals(0, allyEffects.get(PotionEffectType.SPEED).getAmplifier());
|
||||
@@ -83,7 +82,6 @@ class RallyingCryNativeTest {
|
||||
PartyBookNativeTest.click(fixture, caster, unlearn); PartyBookNativeTest.click(fixture, caster, unlearn); drain(fixture, 2);
|
||||
assertEquals(deadline, storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("rallying_cry"));
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "rallying_cry")); drain(fixture, 2);
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[3]", "rallying_cry")); drain(fixture, 2);
|
||||
use(fixture, caster); assertTrue(caster.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,8 +38,6 @@ class ReviveNativeTest {
|
||||
assertTrue(text.contains("CON12")); assertTrue(text.contains("Cooldown:86400s"));
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "revive"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "revive"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
var saved = storage.find(actor.core.record.id()).get().orElseThrow();
|
||||
@@ -298,8 +296,6 @@ class ReviveNativeTest {
|
||||
|
||||
PartyBookNativeTest.click(fixture, caster, PartyBookNativeTest.button(caster, "[Learn]"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
PartyBookNativeTest.click(fixture, caster, PartyBookNativeTest.button(caster, "[3]"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
caster.core.inventory.getInventory().setSelectedSlot(2);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player, target.player,
|
||||
org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
|
||||
@@ -40,7 +40,6 @@ class SanctuaryNativeTest {
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(fixture,caster);
|
||||
String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Sanctuary"));assertTrue(text.contains("WIS24"));assertTrue(text.contains("CHR20"));assertTrue(text.contains("CON10"));
|
||||
PartyBookNativeTest.click(fixture,caster,button(caster,"[Learn]","sanctuary"));drain(fixture,2);
|
||||
PartyBookNativeTest.click(fixture,caster,button(caster,"[3]","sanctuary"));drain(fixture,2);
|
||||
use(fixture,caster);drain(fixture,2);assertEquals(2L,fixture.delayedTicks.getLast());
|
||||
for(int i=0;i<10;i++)fixture.delayed.removeFirst().run();drain(fixture,4);assertEquals(6,ally.core.health,0);
|
||||
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
|
||||
@@ -34,8 +34,6 @@ class SecondWindNativeTest {
|
||||
assertTrue(text.contains("SecondWind")); assertTrue(text.contains("CON12")); assertTrue(text.contains("WIS12"));
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "second_wind"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "second_wind"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(4, a.player.getHealth());
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
@@ -173,8 +171,6 @@ class SecondWindNativeTest {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "second_wind"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "second_wind"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
|
||||
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
|
||||
@@ -94,6 +94,30 @@ class SessionControlsNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void futureCharacterCooldownsUseContainedPanesWhileElapsedAndUnknownControlsKeepTheirPresentation() {
|
||||
var now = java.time.Instant.parse("2000-01-01T00:00:00Z");
|
||||
var clock = java.time.Clock.fixed(now, java.time.ZoneOffset.UTC);
|
||||
var timed = new SessionControls(new games.dmg.fantasystick.ability.AbilityRegistry(), clock);
|
||||
var base = character(List.of("fireball", "fireball", "revive", "removed_spell", "", "", ""));
|
||||
var record = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
|
||||
base.experience(), base.unspentPoints(), base.health(), base.learned(), base.loadout(),
|
||||
Map.of("fireball", now.plusSeconds(5), "revive", now, "removed_spell", now.plusSeconds(5)),
|
||||
base.dead(), base.deathId(), base.diedAt());
|
||||
|
||||
var items = timed.create(record, owner, session);
|
||||
for (int slot : List.of(2, 3)) {
|
||||
var pane = roundTrip(items[slot]);
|
||||
assertEquals(Material.GRAY_STAINED_GLASS_PANE, pane.getType());
|
||||
assertEquals(Component.text("Fireball — Cooling Down"), pane.getItemMeta().displayName());
|
||||
assertEquals("cooldown", pane.getPersistentDataContainer().get(key("control_kind"), PersistentDataType.STRING));
|
||||
assertEquals(java.util.Optional.of("fireball"), timed.ability(pane));
|
||||
assertEquals(new SessionControls.Identity(owner, session, slot), timed.identify(pane).orElseThrow());
|
||||
}
|
||||
assertEquals(Material.ECHO_SHARD, items[4].getType(), "A deadline equal to now is ready");
|
||||
assertEquals(Material.PAPER, items[5].getType(), "Unknown saved IDs remain unavailable, not cooling panes");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownSavedAbilitiesRemainUnavailableWithoutChangingTheRecordOrCallerLoadout() {
|
||||
var loadout = new ArrayList<>(List.of("fireball", "removed_spell", "", "revive", "", "", ""));
|
||||
|
||||
@@ -32,11 +32,9 @@ class ShieldBashNativeTest {
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
String book = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
|
||||
assertTrue(book.contains("ShieldBash")); assertTrue(book.contains("STR12")); assertTrue(book.contains("CON12"));
|
||||
assertTrue(book.contains("Cost:1")); assertTrue(book.contains("3")); assertTrue(book.contains("6s"));
|
||||
assertFalse(book.contains("Cost:")); assertTrue(book.contains("3")); assertTrue(book.contains("6s"));
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "shield_bash"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "shield_bash"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertEquals(BigInteger.valueOf(39), storage.find(a.core.record.id()).get().orElseThrow().unspentPoints());
|
||||
assertEquals(org.bukkit.Material.SHIELD, a.core.inventory.getItem(2).getType());
|
||||
@@ -298,8 +296,6 @@ class ShieldBashNativeTest {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "shield_bash"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "shield_bash"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
|
||||
private static ClickEvent.Custom abilityButton(FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
|
||||
|
||||
@@ -31,11 +31,9 @@ class StoneSkinNativeTest {
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
String text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("StoneSkin")); assertTrue(text.contains("CON14"));
|
||||
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("25%")); assertTrue(text.contains("4s"));
|
||||
assertFalse(text.contains("Cost:")); assertTrue(text.contains("25%")); assertTrue(text.contains("4s"));
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "stone_skin"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "stone_skin"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertEquals(BigInteger.valueOf(39), storage.find(a.core.record.id()).get().orElseThrow().unspentPoints());
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
@@ -58,8 +56,6 @@ class StoneSkinNativeTest {
|
||||
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints()); assertEquals(deadline, refunded.cooldowns().get("stone_skin"));
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "stone_skin"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "stone_skin"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); use(f, a);
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class WarBannerNativeTest {
|
||||
@Test void bookAcquisitionSolidTopPlacementBenefitsPartyAndRewardsUsedDamage()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);var ally=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false,false);f.players.put(caster.core.id,caster.player);f.players.put(ally.core.id,ally.player);f.activate(caster);f.activate(ally);party(f,caster,ally);placement(caster);nearby(caster,List.of(ally.player));var resistance=attribute(ally.player);resistance.modifiers().add(new AttributeModifier(new NamespacedKey("test","unrelated_knockback"),.2,AttributeModifier.Operation.ADD_NUMBER));
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("WarBanner"));assertTrue(text.contains("STR22"));assertTrue(text.contains("CHR22"));assertTrue(text.contains("CON12"));assertTrue(text.contains("25%"));assertTrue(text.contains("50%"));
|
||||
PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","war_banner"));drain(f,2);PartyBookNativeTest.click(f,caster,button(caster,"[3]","war_banner"));drain(f,2);use(f,caster);drain(f,2);assertEquals(200L,f.delayedTicks.getLast());assertEquals(.7,resistance.value(),1e-9);assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("war_banner"));
|
||||
PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","war_banner"));drain(f,2);use(f,caster);drain(f,2);assertEquals(200L,f.delayedTicks.getLast());assertEquals(.7,resistance.value(),1e-9);assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("war_banner"));
|
||||
var enemy=mock(LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(enemy.getLocation()).thenReturn(new Location(caster.world,4,65,0));when(caster.world.getUID()).thenReturn(UUID.randomUUID());var source=DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(ally.player).withDirectEntity(ally.player).build();var hit=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(hit);assertEquals(12.5,hit.getDamage(),1e-9);f.dispatcher.accept(new EntityDeathEvent(enemy,source,new ArrayList<>(),0));drain(f,4);assertEquals(BigInteger.valueOf(5),storage(f).find(caster.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage(f).find(ally.core.record.id()).get().orElseThrow().experience());
|
||||
f.delayed.getLast().run();assertEquals(.2,resistance.value(),1e-9,"Owned cleanup preserves unrelated knockback modifiers");var expired=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(expired);assertEquals(10,expired.getDamage(),1e-9);
|
||||
}
|
||||
|
||||
@@ -169,6 +169,38 @@ class CharacterStorageTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void abilityUnlockFillsLowestEmptySlotAndRefusesAFullLoadoutAtomically() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var partlyOccupied = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
|
||||
base.experience(), BigInteger.TEN, base.health(), java.util.Set.of("known", "unknown_saved"),
|
||||
java.util.List.of("known", "", "unknown_saved", "", "", "", ""), base.cooldowns(),
|
||||
base.restrictedKills(), false, null, null);
|
||||
repository.create(partlyOccupied);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var learned = storage.unlockAbility(partlyOccupied.id(), partlyOccupied.revision(), "new_ability",
|
||||
java.util.Map.of(), BigInteger.ONE).join();
|
||||
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, learned.status());
|
||||
var saved = learned.record().orElseThrow();
|
||||
assertEquals("new_ability", saved.loadout().get(1), "Learning must use the lowest empty slot");
|
||||
assertEquals("unknown_saved", saved.loadout().get(2), "Unknown saved assignments must not be overwritten");
|
||||
assertEquals(BigInteger.valueOf(9), saved.unspentPoints());
|
||||
}
|
||||
|
||||
var full = new CharacterRecord(UUID.randomUUID(), base.name(), BigInteger.ZERO, base.stats(), base.level(),
|
||||
base.experience(), BigInteger.TEN, base.health(), java.util.Set.of("a", "b", "c", "d", "e", "f", "g"),
|
||||
java.util.List.of("a", "b", "c", "d", "e", "f", "g"), base.cooldowns(), base.restrictedKills(), false, null, null);
|
||||
repository.create(full);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var refused = storage.unlockAbility(full.id(), full.revision(), "eighth", java.util.Map.of(), BigInteger.ONE).join();
|
||||
assertEquals(CharacterStorage.AbilityMutationStatus.LOADOUT_FULL, refused.status());
|
||||
assertEquals(full, refused.record().orElseThrow());
|
||||
assertEquals(full, repository.load(full.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void abilityUnlearnRefundsExactlyOnePointClearsEverySlotAndPreservesCooldown() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
|
||||
Reference in new issue
Block a user