Compare commits

...
2 Commits
Author SHA1 Message Date
dmg 5d574dc6e9 feat(admin): autocomplete recovery commands
Release / release (push) Successful in 8m41s
2026-09-20 14:25:23 -04:00
dmg a71262f115 feat(admin): configure party capacity
Release / release (push) Successful in 7m14s
2026-09-20 14:18:24 -04:00
10 changed files with 147 additions and 8 deletions

No files matched your search

@@ -75,6 +75,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.persistence.AdminReviveSettings adminReviveSettings;
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
private games.dmg.fantasystick.persistence.PartySettings partySettings;
private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy;
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
@@ -102,6 +103,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
getConfig().get(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY));
var roleplayDisabled = games.dmg.fantasystick.persistence.RoleplaySettings.parse(
getConfig().get(games.dmg.fantasystick.persistence.RoleplaySettings.KEY));
var partyMaximum = games.dmg.fantasystick.persistence.PartySettings.parse(
getConfig().get(games.dmg.fantasystick.persistence.PartySettings.KEY));
var rules = progressionRules();
var rewardRules = combatRewardRules();
var fireballSettings = fireballSettings();
@@ -162,6 +165,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
gameModes = new games.dmg.fantasystick.session.AdventureModeLease();
switchDelaySettings = new games.dmg.fantasystick.persistence.SwitchDelaySettings(getDataFolder().toPath(), switchDelay);
roleplaySettings = new games.dmg.fantasystick.persistence.RoleplaySettings(getDataFolder().toPath(), roleplayDisabled);
partySettings = new games.dmg.fantasystick.persistence.PartySettings(getDataFolder().toPath(), partyMaximum);
sessions = new EquipmentSessions(characters, receipts, switchDelay, Clock.systemUTC(), nextTick,
notice -> notifyPlayer(generation, notice), diagnostics, presentation, adventureSettings::enabled, gameModes);
sessions.attachSwitchDelay(switchDelaySettings::delay);
@@ -169,7 +173,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
parties = new games.dmg.fantasystick.party.PartyRoster(Clock.systemUTC(), id -> {
var player = getServer().getPlayer(id);
return player != null && player.isOnline();
});
}, partySettings::maximum);
partyEvents = new games.dmg.fantasystick.party.PartyEvents(parties);
partyCombat = new games.dmg.fantasystick.party.PartyCombat(parties, sessions);
combatExperience = new games.dmg.fantasystick.party.CombatExperience(characters, rules, rewardRules,
@@ -267,6 +271,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
admin.attachAdminRevival(adminRevival, adminReviveSettings);
admin.attachSwitchDelay(switchDelaySettings);
admin.attachRoleplaySettings(roleplaySettings);
admin.attachPartySettings(partySettings);
admin.attachCharacterIssuer(new games.dmg.fantasystick.session.AdminCharacterIssuer(characters,
() -> factory.create(rules), nextTick, problem -> getLogger().log(Level.WARNING,
"Admin character issue " + problem.operation() + " for player " + problem.player()
@@ -354,6 +359,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingAdminReviveSettings = adminReviveSettings; adminReviveSettings = null;
var closingSwitchDelaySettings = switchDelaySettings; switchDelaySettings = null;
var closingRoleplaySettings = roleplaySettings; roleplaySettings = null;
var closingPartySettings = partySettings; partySettings = null;
var closingTemporaryHealth = temporaryHealth; temporaryHealth = null;
var closingHexSettings = hexSettings; hexSettings = null;
var closingOutgoingDamageMitigation = outgoingDamageMitigation; outgoingDamageMitigation = null;
@@ -382,6 +388,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingAdminReviveSettings != null) cleanup("admin revival configuration", closingAdminReviveSettings::close);
if (closingSwitchDelaySettings != null) cleanup("switch delay configuration", closingSwitchDelaySettings::close);
if (closingRoleplaySettings != null) cleanup("roleplay configuration", closingRoleplaySettings::close);
if (closingPartySettings != null) cleanup("party configuration", closingPartySettings::close);
configurationDrain = java.util.concurrent.CompletableFuture.allOf(
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSettings.drained().toCompletableFuture(),
@@ -406,7 +413,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
closingSwitchDelaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSwitchDelaySettings.drained().toCompletableFuture(),
closingRoleplaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingRoleplaySettings.drained().toCompletableFuture());
: closingRoleplaySettings.drained().toCompletableFuture(),
closingPartySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingPartySettings.drained().toCompletableFuture());
if (closingBooks != null) cleanup("character book", closingBooks::close);
if (closingAdminRevival != null) cleanup("admin revival", closingAdminRevival::close);
if (closingBasicAttack != null) cleanup("basic attack", closingBasicAttack::close);
@@ -69,12 +69,12 @@ public final class PartyBookControls implements AutoCloseable {
} else {
var group = party.orElseThrow();
boolean leader = group.leader().equals(owner);
summary = summary.append(Component.text(group.members().size() + "/6 members\n\n"))
summary = summary.append(Component.text(group.members().size() + "/" + parties.maximumSize() + " members\n\n"))
.append(build.button("Leave party", () -> mutate(input, () -> parties.leave(owner, group.id()))));
if (leader) {
summary = summary.append(Component.newline())
.append(build.button("Disband party", () -> mutate(input, () -> parties.disband(owner, group.id()))));
if (group.members().size() < 6) summary = summary.append(Component.newline())
if (group.members().size() < parties.maximumSize()) summary = summary.append(Component.newline())
.append(build.button("Invite players", () -> show(input, Menu.PLAYERS, 0)));
}
build.pages.add(summary);
@@ -25,10 +25,19 @@ public final class PartyRoster implements AutoCloseable {
private boolean closed;
private final Clock clock;
private final Predicate<UUID> online;
private final java.util.function.IntSupplier maximumSize;
public PartyRoster(Clock clock, Predicate<UUID> online) {
public PartyRoster(Clock clock, Predicate<UUID> online) { this(clock, online, () -> 6); }
public PartyRoster(Clock clock, Predicate<UUID> online, java.util.function.IntSupplier maximumSize) {
this.clock = Objects.requireNonNull(clock);
this.online = Objects.requireNonNull(online);
this.maximumSize = Objects.requireNonNull(maximumSize);
maximumSize();
}
public int maximumSize() {
int value = maximumSize.getAsInt();
if (value < 1) throw new IllegalStateException("Party maximum must be positive");
return value;
}
public Optional<Party> create(UUID player) {
if (closed || !connected(player) || membership.containsKey(player)) return Optional.empty();
@@ -45,7 +54,7 @@ public final class PartyRoster implements AutoCloseable {
public Optional<Invitation> invite(UUID leader, UUID party, UUID invitee) {
expire();
var group = managedBy(leader, party);
if (group == null || group.members().size() >= 6 || !connected(invitee) || membership.containsKey(invitee)) return Optional.empty();
if (group == null || group.members().size() >= maximumSize() || !connected(invitee) || membership.containsKey(invitee)) return Optional.empty();
var existing = invitations.values().stream().filter(i -> i.party().equals(party) && i.invitee().equals(invitee)).findFirst();
if (existing.isPresent()) return existing;
var invitation = new Invitation(UUID.randomUUID(), party, leader, invitee, clock.instant().plusSeconds(60));
@@ -57,7 +66,7 @@ public final class PartyRoster implements AutoCloseable {
var offer = invitations.get(invitation);
if (offer == null || !offer.invitee().equals(player) || !connected(player) || membership.containsKey(player)) return false;
var group = membership.get(offer.leader());
if (group == null || group.members().size() >= 6 || !group.id().equals(offer.party())) return false;
if (group == null || group.members().size() >= maximumSize() || !group.id().equals(offer.party())) return false;
var members = new ArrayList<>(group.members());
members.add(player);
var updated = new Party(group.id(), group.leader(), members);
@@ -0,0 +1,43 @@
package games.dmg.fantasystick.persistence;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable save-before-publish policy for party capacity checks and book presentation. */
public final class PartySettings implements AutoCloseable {
public static final String KEY="party-max-size";public static final String DEFAULT="6";
private static final int MAX_BYTES=1_048_576;
@FunctionalInterface interface Replacement{void move(Path from,Path to)throws IOException;}
public record Update(String value,int maximum,long sequence){}
private final Path directory;private final Replacement replacement;private final java.util.concurrent.ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-party-configuration"));
private volatile int effective;private long persistedSequence,publishedSequence;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
public PartySettings(Path directory,int initial){this(directory,initial,(from,to)->Files.move(from,to,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING));}
PartySettings(Path directory,int initial,Replacement replacement){this.directory=Objects.requireNonNull(directory).toAbsolutePath();this.replacement=Objects.requireNonNull(replacement);effective=validate(initial);}
public static int parse(Object value){if(value==null)return 6;if(!(value instanceof Number number))throw new IllegalArgumentException(KEY+" must be a positive whole number");try{return validate(new java.math.BigDecimal(number.toString()).intValueExact());}catch(ArithmeticException|NumberFormatException invalid){throw new IllegalArgumentException(KEY+" must be a representable positive whole number",invalid);}}
private static int parseText(String value){if(value==null||!value.matches("[0-9]+"))throw new IllegalArgumentException(KEY+" must be a positive whole number");try{return validate(new java.math.BigInteger(value).intValueExact());}catch(ArithmeticException|NumberFormatException invalid){throw new IllegalArgumentException(KEY+" must be a representable positive whole number",invalid);}}
private static int validate(int value){if(value<1)throw new IllegalArgumentException(KEY+" must be a positive whole number");return value;}
public int maximum(){return effective;}public String value(){return Integer.toString(effective);}
public CompletionStage<Update> set(String raw){int accepted=parseText(raw);synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration store is closed"));pending++;}try{return CompletableFuture.supplyAsync(()->{try{save(accepted);return new Update(Integer.toString(accepted),accepted,++persistedSequence);}catch(IOException|RuntimeException failure){throw new java.util.concurrent.CompletionException(new IOException("Party configuration save refused or failed; effective setting unchanged"));}finally{finished();}},worker).minimalCompletionStage();}catch(RejectedExecutionException rejected){finished();return CompletableFuture.failedStage(new IOException("Configuration store is closed"));}}
public synchronized boolean publish(Update update){if(update.sequence()<=publishedSequence)return false;effective=validate(update.maximum());publishedSequence=update.sequence();return true;}
private void save(int value)throws IOException{Files.createDirectories(directory);if(!Files.isDirectory(directory,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe directory");var file=directory.resolve("config.yml");var lockFile=directory.resolve(".config.lock");if(Files.exists(lockFile,LinkOption.NOFOLLOW_LINKS)&&!Files.isRegularFile(lockFile,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe lock");try(var channel=FileChannel.open(lockFile,StandardOpenOption.CREATE,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS);var lock=channel.tryLock()){if(lock==null)throw new IOException("Configuration busy");var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(20);options.setCodePointLimit(MAX_BYTES);var yaml=new Yaml(new SafeConstructor(options));Map<String,Object> values=new LinkedHashMap<>();if(Files.exists(file,LinkOption.NOFOLLOW_LINKS)){if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe configuration");byte[] bytes;try(var input=Files.newInputStream(file,LinkOption.NOFOLLOW_LINKS)){bytes=input.readNBytes(MAX_BYTES+1);}if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Object loaded=yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());if(loaded!=null){if(!(loaded instanceof Map<?,?> map))throw new IOException("Invalid configuration root");for(var entry:map.entrySet()){if(!(entry.getKey() instanceof String key))throw new IOException("Invalid configuration key");values.put(key,entry.getValue());}}}values.put(KEY,value);byte[] bytes=yaml.dump(values).getBytes(StandardCharsets.UTF_8);if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Path temporary=Files.createTempFile(directory,".config-",".tmp");try{try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS)){var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}replacement.move(temporary,file);}finally{Files.deleteIfExists(temporary);}}catch(OverlappingFileLockException busy){throw new IOException("Configuration busy");}}
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
}
@@ -43,6 +43,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.persistence.AdminReviveSettings adminReviveSettings;
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
private games.dmg.fantasystick.persistence.PartySettings partySettings;
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
@@ -214,6 +215,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
public void attachSwitchDelay(games.dmg.fantasystick.persistence.SwitchDelaySettings settings){if(closed||switchDelaySettings!=null)throw new IllegalStateException("Switch delay already attached or command closed");switchDelaySettings=Objects.requireNonNull(settings);}
public void attachRoleplaySettings(games.dmg.fantasystick.persistence.RoleplaySettings settings){if(closed||roleplaySettings!=null)throw new IllegalStateException("Roleplay settings already attached or command closed");roleplaySettings=Objects.requireNonNull(settings);}
public void attachPartySettings(games.dmg.fantasystick.persistence.PartySettings settings){if(closed||partySettings!=null)throw new IllegalStateException("Party settings already attached or command closed");partySettings=Objects.requireNonNull(settings);}
public void attachAdminRevival(AdminRevival revival, games.dmg.fantasystick.persistence.AdminReviveSettings settings) {
if (closed || adminRevival != null) throw new IllegalStateException("Admin revival already attached or command closed");
@@ -333,6 +335,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (args[1].equals("adventure-mode")) { adventureConfiguration(sender, args); return; }
if (args[1].equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY)) { switchDelayConfiguration(sender,args); return; }
if (args[1].equals(games.dmg.fantasystick.persistence.RoleplaySettings.KEY)) { roleplayConfiguration(sender,args); return; }
if (args[1].equals(games.dmg.fantasystick.persistence.PartySettings.KEY)) { partyConfiguration(sender,args); return; }
if (args[1].equals(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY)) { adminReviveConfiguration(sender,args); return; }
if (longbowSettings != null && games.dmg.fantasystick.persistence.LongbowSettings.keys().contains(args[1])) {
longbowConfiguration(sender, args); return;
@@ -544,6 +547,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
});
}
private void partyConfiguration(CommandSender sender,String[] args){if(partySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.PartySettings.KEY+"="+partySettings.value()+" (existing parties are retained)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartySettings.Update> operation;try{operation=partySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party maximum is invalid; enter a positive whole number.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-party",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null)applied=partySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Party configuration save failed; the effective limit and rosters are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+partySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+saved.value()+"; existing members are retained.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void roleplayConfiguration(CommandSender sender,String[] args){if(roleplaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value());return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RoleplaySettings.Update> operation;try{operation=roleplaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Usage: /fantasystick config roleplay-disabled [true|false]");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-roleplay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=roleplaySettings.publish(saved);if(applied&&saved.disabled())sessions.disableRoleplay();}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Roleplay configuration save failed; policy and sessions are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+saved.disabled()+(saved.disabled()?"; ordinary exits were requested for active sessions.":"; normal sheet activation is available."));});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void switchDelayConfiguration(CommandSender sender,String[] args){if(switchDelaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+switchDelaySettings.value()+" (new exits use this delay; existing deadlines are unchanged)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SwitchDelaySettings.Update> operation;try{operation=switchDelaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Character switch delay is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-switch-delay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null)switchDelaySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+saved.value()+"; new exits use it and existing deadlines are unchanged.":"Character switch delay save failed; the effective value is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
@@ -587,10 +592,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed || !sender.hasPermission(PERMISSION)) return List.of();
if (args.length == 1) return matching(sender instanceof org.bukkit.entity.Player
? List.of("config", "give", "inspect", "recover", "revive") : List.of("config", "give", "inspect", "recover"), args[0]);
if (args.length == 2 && (args[0].equals("inspect") || args[0].equals("recover"))) return matching(
server.getOnlinePlayers().stream().filter(org.bukkit.entity.Player::isOnline)
.map(player -> player.getUniqueId().toString()).toList(), args[1]);
if (args.length == 2 && (args[0].equals("give") || args[0].equals("revive") && sender instanceof org.bukkit.entity.Player)) return matching(server.getOnlinePlayers().stream()
.filter(org.bukkit.entity.Player::isOnline).map(org.bukkit.entity.Player::getName).toList(), args[1]);
if (args.length == 2 && args[0].equals("config")) {
var keys = new java.util.ArrayList<String>(); keys.add("adventure-mode");keys.add(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY);keys.add(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.RoleplaySettings.KEY);
var keys = new java.util.ArrayList<String>(); keys.add("adventure-mode");keys.add(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY);keys.add(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.RoleplaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.PartySettings.KEY);
keys.addAll(games.dmg.fantasystick.persistence.FrostShardSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.LongbowSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.QuickstepSettings.keys());
@@ -606,6 +614,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if(args[1].equals(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY)&&adminReviveSettings!=null){String current=adminReviveSettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.AdminReviveSettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.AdminReviveSettings.DEFAULT),args[2]);}
if(args[1].equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY)&&switchDelaySettings!=null){String current=switchDelaySettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.SwitchDelaySettings.DEFAULT),args[2]);}
if(args[1].equals(games.dmg.fantasystick.persistence.RoleplaySettings.KEY)&&roleplaySettings!=null)return matching(List.of("false","true"),args[2]);
if(args[1].equals(games.dmg.fantasystick.persistence.PartySettings.KEY)&&partySettings!=null){String current=partySettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.PartySettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.PartySettings.DEFAULT),args[2]);}
if (frostSettings != null && games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
String current = frostSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.FrostShardSettings.defaultValue(args[1]);
+1
View File
@@ -6,6 +6,7 @@ admin-revive-selection-timeout-seconds: 60
# Real-time delay before this player may enter a different character after exit.
character-swap-cooldown-seconds: 900
roleplay-disabled: false
party-max-size: 6
# New-character and transferable fantasy progression defaults.
starting-points: 40
@@ -84,6 +84,39 @@ class EquipmentAdminCommandNativeTest {
release.countDown();}}
}
@Test void recoveryCompletionIsPermissionAwarePrefixFilteredUuidOnlyAndReadOnly() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var first = new FantasyStickLifecycleNativeTest.Actor(data);
var second = new FantasyStickLifecycleNativeTest.Actor(data);
var offline = new FantasyStickLifecycleNativeTest.Actor(data);
offline.core.online = false;
f.players.put(first.core.id, first.player); f.players.put(second.core.id, second.player); f.players.put(offline.core.id, offline.player);
var sender = new Sender();
var expected = java.util.stream.Stream.of(first.core.id.toString(), second.core.id.toString()).sorted().toList();
assertEquals(List.of("config", "give", "inspect", "recover"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(List.of("inspect"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"in"}));
assertEquals(List.of("recover"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"rec"}));
assertEquals(expected, f.command.tabComplete(sender.port, "fantasystick", new String[]{"inspect", ""}));
String prefix = expected.getFirst().substring(0, 8);
assertEquals(expected.stream().filter(id -> id.startsWith(prefix)).toList(),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"recover", prefix}));
assertFalse(f.command.tabComplete(sender.port, "fantasystick", new String[]{"inspect", ""}).contains("Account"));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"inspect", expected.getFirst(), ""}).isEmpty());
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unknown", ""}).isEmpty());
assertEquals(List.of("adventure-mode"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "adventure-m"}));
assertEquals(List.of("false", "true"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "adventure-mode", ""}));
assertEquals(List.of("true"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "adventure-mode", "t"}));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "adventure-mode", "true", ""}).isEmpty());
sender.allowed = false;
for (String[] args : List.of(new String[]{""}, new String[]{"inspect", ""}, new String[]{"config", ""},
new String[]{"config", "adventure-mode", ""})) {
assertTrue(f.command.tabComplete(sender.port, "fantasystick", args).isEmpty());
}
assertTrue(f.tasks.isEmpty()); assertFalse(Files.exists(data.resolve("sessions")));
}
}
@Test void adventureConfigurationCommandReadsValidatesPersistsAndReopensWithoutTouchingPlayers() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -0,0 +1,7 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;import java.nio.file.Path;import org.junit.jupiter.api.BeforeAll;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
class PartyCapacityNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void registeredPolicyDrivesActualBookInvitationsAcceptanceGrandfatheringAndRestart()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();assertEquals(java.util.List.of("party-max-size"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","party-m"}));assertEquals(java.util.List.of("6"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","party-max-size",""}));f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size"});assertTrue(sender.messages.getLast().contains("=6"));for(String bad:java.util.List.of("0","-1","1.5","bad","2147483648")){f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size",bad});assertTrue(sender.messages.getLast().contains("invalid"));}sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","party"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size","2"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size","2","extra"});assertTrue(sender.messages.getLast().contains("Usage"));save(f,sender,"2");var leader=new FantasyStickLifecycleNativeTest.Actor(data);var member=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(leader.core.id,leader.player);f.players.put(member.core.id,member.player);f.activate(leader);f.activate(member);PartyBookNativeTest.open(f,leader);PartyBookNativeTest.click(f,leader,PartyBookNativeTest.button(leader,"[Create party]"));PartyBookNativeTest.drain(f);PartyBookNativeTest.click(f,leader,PartyBookNativeTest.button(leader,"[Invite players]"));PartyBookNativeTest.click(f,leader,PartyBookNativeTest.button(leader,"[Invite]"));PartyBookNativeTest.open(f,member);PartyBookNativeTest.click(f,member,PartyBookNativeTest.button(member,"[Invitations]"));PartyBookNativeTest.click(f,member,PartyBookNativeTest.button(member,"[Accept]"));PartyBookNativeTest.drain(f);var parties=PartyLifecycleNativeTest.parties(f);assertEquals(2,parties.partyOf(leader.core.id).orElseThrow().members().size());assertTrue(PartyBookNativeTest.text(member).contains("2/2 members"));PartyBookNativeTest.open(f,leader);assertFalse(PartyBookNativeTest.text(leader).contains("[Invite players]"));save(f,sender,"3");var third=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(third.core.id,third.player);f.activate(third);PartyBookNativeTest.open(f,leader);assertTrue(PartyBookNativeTest.text(leader).contains("2/3 members"));PartyBookNativeTest.click(f,leader,PartyBookNativeTest.button(leader,"[Invite players]"));PartyBookNativeTest.click(f,leader,PartyBookNativeTest.button(leader,"[Invite]"));assertEquals(1,parties.invitationsFor(third.core.id).size());save(f,sender,"2");PartyBookNativeTest.open(f,third);PartyBookNativeTest.click(f,third,PartyBookNativeTest.button(third,"[Invitations]"));PartyBookNativeTest.click(f,third,PartyBookNativeTest.button(third,"[Accept]"));PartyBookNativeTest.drain(f);assertTrue(parties.partyOf(third.core.id).isEmpty());assertEquals(2,parties.partyOf(leader.core.id).orElseThrow().members().size());save(f,sender,"3");PartyBookNativeTest.open(f,third);PartyBookNativeTest.click(f,third,PartyBookNativeTest.button(third,"[Invitations]"));PartyBookNativeTest.click(f,third,PartyBookNativeTest.button(third,"[Accept]"));PartyBookNativeTest.drain(f);assertEquals(3,parties.partyOf(leader.core.id).orElseThrow().members().size());save(f,sender,"2");assertEquals(3,parties.partyOf(leader.core.id).orElseThrow().members().size());assertTrue(PartyCombatNativeTest.hit(f,leader.player,member.player).isCancelled());PartyBookNativeTest.open(f,leader);assertTrue(PartyBookNativeTest.text(leader).contains("3/2 members"));assertFalse(PartyBookNativeTest.text(leader).contains("[Invite players]"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size"});assertTrue(sender.messages.getLast().contains("=2"));assertTrue(PartyLifecycleNativeTest.parties(f).partyOf(leader.core.id).isEmpty());}}
@Test void failedRegisteredSaveLeavesCurrentRosterAndLimitUnchanged()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();var leader=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(leader.core.id,leader.player);var party=PartyLifecycleNativeTest.parties(f).create(leader.core.id).orElseThrow();java.nio.file.Files.createDirectory(data.resolve("config.yml"));f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size","2"});var callback=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(callback);callback.run();assertTrue(sender.messages.getLast().contains("save failed"));assertEquals(party,PartyLifecycleNativeTest.parties(f).partyOf(leader.core.id).orElseThrow());f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size"});assertTrue(sender.messages.getLast().contains("=6"));}}
static void save(FantasyStickLifecycleNativeTest.Fixture f,EquipmentAdminCommandNativeTest.Sender sender,String value)throws Exception{int before=sender.messages.size();f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size",value});assertEquals(before,sender.messages.size());while(sender.messages.size()==before){var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}assertTrue(sender.messages.getLast().contains("="+value));}
}
@@ -108,6 +108,28 @@ class PartyRosterTest {
assertEquals(6, parties.partyOf(leader).orElseThrow().members().size());
}
@Test void liveCapacityGrandfathersExistingMembersAndRechecksPendingInvitations() {
var limit = new java.util.concurrent.atomic.AtomicInteger(3);
try (var dynamic = new PartyRoster(clock, online::contains, limit::get)) {
UUID leader = player(), first = player(), second = player(), pending = player(), later = player();
var party = dynamic.create(leader).orElseThrow();
assertTrue(dynamic.accept(first, dynamic.invite(leader, party.id(), first).orElseThrow().id()));
var pendingOffer = dynamic.invite(leader, party.id(), pending).orElseThrow();
assertTrue(dynamic.accept(second, dynamic.invite(leader, party.id(), second).orElseThrow().id()));
limit.set(2);
assertEquals(3, dynamic.partyOf(leader).orElseThrow().members().size());
assertFalse(dynamic.accept(pending, pendingOffer.id()));
assertTrue(dynamic.invite(leader, party.id(), later).isEmpty());
assertTrue(dynamic.leave(first, party.id()));
assertTrue(dynamic.invite(leader, party.id(), later).isEmpty(), "At the limit cannot grow");
assertTrue(dynamic.leave(second, party.id()));
assertTrue(dynamic.accept(later, dynamic.invite(leader, party.id(), later).orElseThrow().id()));
limit.set(4);
assertEquals(4, dynamic.maximumSize());
assertTrue(dynamic.invite(leader, party.id(), pending).isPresent());
}
}
@Test void onlyTheCurrentOnlineLeaderCanKickOrDisbandTheMatchingParty() {
UUID leader = player(), member = player(), stranger = player();
var party = parties.create(leader).orElseThrow();
@@ -0,0 +1,6 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;import java.nio.file.*;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
class PartySettingsTest{@TempDir Path data;
@Test void validatesPersistsPreservesUnrelatedAndRejectsStalePublication()throws Exception{assertEquals(6,PartySettings.parse(null));assertEquals(1,PartySettings.parse(1));assertEquals(Integer.MAX_VALUE,PartySettings.parse(Integer.MAX_VALUE));for(Object bad:new Object[]{0,-1,"1",1.5,"2147483648"})assertThrows(IllegalArgumentException.class,()->PartySettings.parse(bad));Files.writeString(data.resolve("config.yml"),"unrelated: keep\nparty-max-size: 6\n");try(var settings=new PartySettings(data,6)){var one=settings.set("2").toCompletableFuture().get();var two=settings.set("3").toCompletableFuture().get();assertEquals(6,settings.maximum());settings.publish(two);settings.publish(one);assertEquals(3,settings.maximum());assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));}}
@Test void failedWriteRetainsEffectiveValue()throws Exception{try(var settings=new PartySettings(data,6)){Files.createDirectory(data.resolve("config.yml"));assertThrows(Exception.class,()->settings.set("2").toCompletableFuture().get());assertEquals(6,settings.maximum());}}
}