Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import java.util.ArrayList;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
Expand Down Expand Up @@ -210,6 +211,51 @@ public QuickPreview previewQuickSetup(String preset, Map<String, String> options
changes(parse(current), parse(proposal.content())));
}

/** Returns the small, non-secret state represented by a guided setup form. */
public QuickState readQuickSetup(String preset, Map<String, String> options) throws IOException {
String fileName = quickSetupFile(preset);
String current = readRaw(resolve(fileName), false);
YamlConfiguration yaml = parse(current);
Map<String, String> values = new LinkedHashMap<>();
if ("standalone".equals(preset) || "proxy-backend".equals(preset)) {
values.put("useBungeecord", String.valueOf(yaml.getBoolean("UseBungeecord", false)));
values.put("server", yaml.getString("Server", ""));
values.put("method", canonicalBungeeMethod(yaml.getString("BungeeMethod", "PLUGINMESSAGING")));
} else if ("vote-site".equals(preset)) {
String name = option(options, "name", "[A-Za-z0-9_-]{1,64}");
String root = "VoteSites." + name;
values.put("name", name);
values.put("exists", String.valueOf(yaml.isConfigurationSection(root)));
values.put("enabled", String.valueOf(yaml.getBoolean(root + ".Enabled", true)));
values.put("displayName", yaml.getString(root + ".Name", name));
values.put("priority", String.valueOf(yaml.getInt(root + ".Priority", 5)));
values.put("hidden", String.valueOf(yaml.getBoolean(root + ".Hidden", false)));
values.put("serviceSite", yaml.getString(root + ".ServiceSite", ""));
values.put("voteUrl", yaml.getString(root + ".VoteURL", ""));
values.put("voteDelay", yaml.getString(root + ".VoteDelay", "24h"));
values.put("material", yaml.getString(root + ".DisplayItem.Material", "DIAMOND"));
} else if ("common-settings".equals(preset)) {
values.put("processRewards", String.valueOf(yaml.getBoolean("ProcessRewards", true)));
values.put("autoCreateVoteSites", String.valueOf(yaml.getBoolean("AutoCreateVoteSites", true)));
values.put("extraAllSitesCheck", String.valueOf(yaml.getBoolean("ExtraAllSitesCheck", false)));
values.put("countFakeVotes", String.valueOf(yaml.getBoolean("CountFakeVotes", true)));
values.put("disableNoServiceSiteMessage",
String.valueOf(yaml.getBoolean("DisableNoServiceSiteMessage", false)));
values.put("disableUpdateChecking", String.valueOf(yaml.getBoolean("DisableUpdateChecking", false)));
} else if ("vote-party".equals(preset)) {
values.put("enabled", String.valueOf(yaml.getBoolean("VoteParty.Enabled", false)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the returned vote-party enabled state

When a disabled vote party is read and the returned options are submitted unchanged while editing another field, this reports enabled=false, but quickProposal() ignores that option and unconditionally writes VoteParty.Enabled: true. Thus an apparently round-trip edit silently enables vote-party processing; either honor the returned enabled option during preview/apply or omit it if this preset is intentionally enable-only.

Useful? React with 👍 / 👎.

values.put("votesRequired", String.valueOf(yaml.getInt("VoteParty.VotesRequired", 20)));
values.put("broadcast", yaml.getString("VoteParty.Broadcast", ""));
values.put("giveAllPlayers", String.valueOf(yaml.getBoolean("VoteParty.GiveAllPlayers", false)));
values.put("onlineOnly", String.valueOf(yaml.getBoolean("VoteParty.GiveOnlinePlayersOnly", true)));
values.put("rewardCommandCount",
String.valueOf(yaml.getStringList("VoteParty.Rewards.Commands").size()));
} else {
throw new IllegalArgumentException("quick setup preset cannot be read");
Comment on lines +253 to +254

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Support reads for the advertised proxy-method preset

When Control sends a READ operation for proxy-method, executeQuick() now routes it here, but this method rejects the preset even though quickSetupFile()/quickProposal() support it and the backend advertises config.proxy-method.v1. As a result, the guided transport-method UI cannot load the backend's current method and receives VALIDATION_ERROR; handle proxy-method by returning the installed BungeeMethod, as the proxy connector already does.

Useful? React with 👍 / 👎.

}
return new QuickState(Map.copyOf(values), quickSetupRevision(preset, current));
}

String proposedQuickSetupRevision(String preset, QuickPreview preview) throws IOException {
return quickSetupRevision(preset, preview.proposal().content());
}
Expand Down Expand Up @@ -283,10 +329,10 @@ private QuickProposal quickProposal(String preset, Map<String, String> options,
if ("vote-site".equals(preset)) {
String name = option(options, "name", "[A-Za-z0-9_-]{1,64}");
String root = "VoteSites." + name;
yaml.set(root + ".Enabled", true);
yaml.set(root + ".Enabled", booleanOption(options, "enabled", true));
yaml.set(root + ".Name", options.getOrDefault("displayName", name));
yaml.set(root + ".Priority", boundedInteger(options.getOrDefault("priority", "5"), 0, 100));
yaml.set(root + ".Hidden", false);
yaml.set(root + ".Hidden", booleanOption(options, "hidden", false));
yaml.set(root + ".ServiceSite", option(options, "serviceSite", ".{1,200}"));
yaml.set(root + ".VoteURL", option(options, "voteUrl", ".{1,500}"));
yaml.set(root + ".VoteDelay", options.getOrDefault("voteDelay", "24h"));
Expand All @@ -309,8 +355,10 @@ private QuickProposal quickProposal(String preset, Map<String, String> options,
if (command.isBlank() && message.isBlank()) {
throw new IllegalArgumentException("easy reward requires a command or player message");
}
if (!command.isBlank()) yaml.set(root + ".Commands", List.of(command));
if (!message.isBlank()) yaml.set(root + ".Messages.Player", message);
if (!command.isBlank()) appendUniqueString(yaml, root + ".Commands", command);
if (!message.isBlank() && !yaml.contains(root + ".Messages.Player")) {
yaml.set(root + ".Messages.Player", message);
}
return new QuickProposal(fileName, yaml.saveToString());
}
if ("common-settings".equals(preset)) {
Expand All @@ -329,13 +377,30 @@ private QuickProposal quickProposal(String preset, Map<String, String> options,
yaml.set("VoteParty.GiveOnlinePlayersOnly", booleanOption(options, "onlineOnly"));
String command = optional(options, "command", 500);
String broadcast = optional(options, "broadcast", 500);
if (!command.isBlank()) yaml.set("VoteParty.Rewards.Commands", List.of(command));
if (!command.isBlank()) appendUniqueString(yaml, "VoteParty.Rewards.Commands", command);
if (!broadcast.isBlank()) yaml.set("VoteParty.Broadcast", broadcast);
return new QuickProposal(fileName, yaml.saveToString());
}
throw new IllegalArgumentException("quick setup preset is unsupported");
}

private static void appendUniqueString(YamlConfiguration yaml, String path, String value) {
Object current = yaml.get(path);
List<String> values = new ArrayList<>();
if (current instanceof List<?> listed) {
for (Object item : listed) {
if (!(item instanceof String text)) {
throw new IllegalArgumentException(path + " contains a non-text entry; use the full YAML editor");
}
values.add(text);
}
} else if (current != null) {
throw new IllegalArgumentException(path + " is not a list; use the full YAML editor");
}
if (!values.contains(value)) values.add(value);
yaml.set(path, values);
}

private boolean caseInsensitiveYmlFiles() throws IOException {
Path config = resolve("Config.yml");
if (!Files.exists(config)) return false;
Expand Down Expand Up @@ -820,6 +885,11 @@ private static boolean booleanOption(Map<String, String> options, String name) {
return Boolean.parseBoolean(value);
}

private static boolean booleanOption(Map<String, String> options, String name, boolean defaultValue) {
if (options == null || !options.containsKey(name)) return defaultValue;
return booleanOption(options, name);
}

private static int boundedInteger(String value, int minimum, int maximum) {
try {
int parsed = Integer.parseInt(value);
Expand All @@ -835,6 +905,7 @@ public record Preview(String fileName, String resolvedContent, String revision,
public record ApplyResult(Document document, List<String> changes, boolean rolledBack) { }
public record QuickProposal(String fileName, String content) { }
public record QuickPreview(QuickProposal proposal, String revision, List<String> changes) { }
public record QuickState(Map<String, String> options, String revision) { }

@FunctionalInterface public interface ReloadAction { void run() throws Exception; }
@FunctionalInterface public interface ApplyAction { void run(String fileName) throws Exception; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
public final class BackendControlConnector implements AutoCloseable {
private static final int PROTOCOL_VERSION = 1;
private static final int MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
private static final long OPERATION_POLL_MILLIS = 1000;
private static final long SHUTDOWN_TIMEOUT_SECONDS = 65;
private static final Pattern NODE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}");
private static final Set<String> CAPABILITIES = Set.of("config.files.v1", "config.file-comments.v1",
Expand Down Expand Up @@ -69,6 +70,7 @@ public final class BackendControlConnector implements AutoCloseable {
private volatile boolean voteSitesSyncAccepted;
private volatile int failures;
private volatile ScheduledFuture<?> scheduled;
private volatile ScheduledFuture<?> operationPolling;
private volatile Future<?> activeReload;
private volatile CompletableFuture<Void> activeOperation;

Expand Down Expand Up @@ -158,7 +160,45 @@ public boolean isClosed() {
return closed;
}

public void start() { schedule(0); }
public void start() {
if (closed) return;
schedule(0);
operationPolling = executor.scheduleWithFixedDelay(this::pollOperations,
OPERATION_POLL_MILLIS, OPERATION_POLL_MILLIS, TimeUnit.MILLISECONDS);
}

/** Claims configuration work independently of the lower-frequency presence heartbeat. */
private void pollOperations() {
if (closed || !registered || failures != 0 || !operationsAccepted
|| !running.compareAndSet(false, true)) return;
CompletableFuture<Void> operation = new CompletableFuture<>();
synchronized (operationLifecycle) {
if (closed) {
running.set(false);
return;
}
activeOperation = operation;
}
try {
claimAndExecute();
} catch (Exception failure) {
registered = false;
failures = Math.min(30, failures + 1);
if (failures == 1 || failures % 10 == 0) {
plugin.getLogger().warning("[Control] Bukkit operation polling unavailable; VotingPlugin remains active");
}
ScheduledFuture<?> heartbeat = scheduled;
if (heartbeat != null) heartbeat.cancel(false);
if (!closed) schedule(Math.min(TimeUnit.MINUTES.toMillis(5),
1000L << Math.min(failures - 1, 8)));
} finally {
operation.complete(null);
synchronized (operationLifecycle) {
if (activeOperation == operation) activeOperation = null;
}
running.set(false);
}
}

private void schedule(long delayMillis) {
if (!closed) scheduled = executor.schedule(this::cycle, delayMillis, TimeUnit.MILLISECONDS);
Expand Down Expand Up @@ -450,7 +490,7 @@ private TaskResult execute(UUID operationId, JsonObject task) {
} catch (BackendConfigurationService.StaleRevisionException e) {
return TaskResult.failure("STALE_REVISION", "Configuration changed after preview");
} catch (BackendConfigurationService.ApplyFailureException e) {
return TaskResult.failure("RELOAD_FAILED", "Reload failed after persistence", e.rolledBack());
return TaskResult.failure("RELOAD_FAILED", failureMessage("Reload failed", e), e.rolledBack());
} catch (IllegalArgumentException e) {
return TaskResult.failure("VALIDATION_ERROR", e.getMessage());
} catch (Exception e) {
Expand Down Expand Up @@ -487,12 +527,15 @@ private TaskResult executeFile(UUID operationId, String type, JsonObject configu

private TaskResult executeQuick(UUID operationId, String type, JsonObject configuration, JsonObject task)
throws IOException {
if ("READ".equals(type)) return TaskResult.failure("UNSUPPORTED_TASK", "Quick setups cannot be read");
String preset = string(configuration, "preset");
if (!quickSetupCapabilityAccepted(preset, quickSetupsAccepted, voteSitesSyncAccepted)) {
return TaskResult.failure("UNSUPPORTED_TASK", "VoteSites sync was not negotiated");
}
Map<String, String> options = options(configuration.getAsJsonObject("options"));
if ("READ".equals(type)) {
BackendConfigurationService.QuickState state = configurations.readQuickSetup(preset, options);
return TaskResult.quick(preset, state.options(), state.revision(), List.of(), false);
}
if ("PREVIEW".equals(type)) {
BackendConfigurationService.QuickPreview preview = configurations.previewQuickSetup(preset, options);
return TaskResult.quick(preset, options, preview.revision(), preview.changes(), false);
Expand All @@ -510,6 +553,19 @@ private TaskResult executeQuick(UUID operationId, String type, JsonObject config
return TaskResult.failure("UNSUPPORTED_TASK", "Task type is unsupported");
}

static String failureMessage(String prefix, Throwable failure) {
Throwable detail = failure;
while (detail.getCause() != null && (detail instanceof BackendConfigurationService.ApplyFailureException
|| detail instanceof java.util.concurrent.ExecutionException
|| detail instanceof java.util.concurrent.CompletionException
|| detail.getMessage() == null || detail.getMessage().isBlank())) detail = detail.getCause();
String message = detail.getMessage();
if (message == null || message.isBlank()) message = detail.getClass().getSimpleName();
message = message.replaceAll("[\\p{Cntrl}&&[^\\t]]", " ").trim();
if (message.length() > 240) message = message.substring(0, 237) + "...";
return prefix + ": " + message;
}

static boolean quickSetupCapabilityAccepted(String preset, boolean quickSetupsAccepted,
boolean voteSitesSyncAccepted) {
return quickSetupsAccepted && (!"sync-vote-sites".equals(preset) || voteSitesSyncAccepted);
Expand Down Expand Up @@ -594,6 +650,8 @@ public void close() {
}
ScheduledFuture<?> current = scheduled;
if (current != null) current.cancel(false);
ScheduledFuture<?> polling = operationPolling;
if (polling != null) polling.cancel(false);
if (reload != null && Bukkit.isPrimaryThread()) reload.cancel(false);
awaitShutdown(executor, operation);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ public final class ControlConnector implements AutoCloseable {
private static final String PROXY_METHOD_CAPABILITY = "config.proxy-method.v1";
private static final String PROXY_METHOD_PRESET = "proxy-method";
private static final String INTERNAL_OPERATION_TYPE = "_controlOperationType";
private static final long OPERATION_POLL_MILLIS = 1000;
private static final long MAX_BACKOFF_MILLIS = TimeUnit.MINUTES.toMillis(5);
private static final long OPERATION_SHUTDOWN_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(65);

Expand Down Expand Up @@ -87,6 +88,7 @@ public final class ControlConnector implements AutoCloseable {
private volatile int failures;
private volatile long snapshotSequence;
private volatile ScheduledFuture<?> scheduled;
private volatile ScheduledFuture<?> operationPolling;
private volatile CompletableFuture<?> activeRequest;
private volatile CompletableFuture<Void> activeOperation;
private volatile Status status = Status.STARTING;
Expand Down Expand Up @@ -202,6 +204,52 @@ public void start() {
}
status = Status.STARTING;
schedule(0);
operationPolling = scheduler.scheduleWithFixedDelay(this::pollOperations,
OPERATION_POLL_MILLIS, OPERATION_POLL_MILLIS, TimeUnit.MILLISECONDS);
}

/** Polls only the operation queue; heartbeat and presence retain their configured cadence. */
void pollOperations() {
CompletableFuture<Void> operationDone;
synchronized (operationLifecycle) {
if (closed || !registered || status != Status.CONNECTED || !configurationAccepted
|| !inFlight.compareAndSet(false, true)) return;
Comment thread
BenCodez marked this conversation as resolved.
operationDone = new CompletableFuture<>();
activeOperation = operationDone;
}
CompletableFuture<Void> operation;
try {
if (hasCompletedTask()) {
operation = submitCompletedResult();
} else {
CompletableFuture<Response> claim = transport.send(claimRequest());
activeRequest = claim;
operation = claim.thenCompose(this::handleClaimResponse);
}
} catch (RuntimeException failure) {
operation = new CompletableFuture<>();
operation.completeExceptionally(failure);
}
operation.whenComplete((ignored, failure) -> {
Throwable cause = failure == null ? null : unwrap(failure);
try {
activeRequest = null;
if (cause == null) {
operationDone.complete(null);
} else {
registered = false;
operationDone.completeExceptionally(cause);
}
} finally {
if (activeOperation == operationDone) activeOperation = null;
finishCycle();
}
if (cause != null && !closed) {
ScheduledFuture<?> heartbeat = scheduled;
if (heartbeat != null) heartbeat.cancel(false);
onFailure(cause);
}
});
}

public Status status() {
Expand Down Expand Up @@ -247,7 +295,13 @@ private void schedule(long delayMillis) {

void cycle() {
synchronized (operationLifecycle) {
if (closed || !inFlight.compareAndSet(false, true)) return;
if (closed) return;
if (!inFlight.compareAndSet(false, true)) {
// A fast operation claim can overlap the one-shot heartbeat. Re-arm
// it so presence does not stop after this collision.
schedule(OPERATION_POLL_MILLIS);
return;
}
}
Request first = registered ? heartbeatRequest() : registrationRequest();
CompletableFuture<Response> primary;
Expand Down Expand Up @@ -851,6 +905,8 @@ public void close() {
if (scheduledRequest != null) {
scheduledRequest.cancel(false);
}
ScheduledFuture<?> polling = operationPolling;
if (polling != null) polling.cancel(false);
CompletableFuture<?> request = activeRequest;
if (request != null) {
request.cancel(true);
Expand Down
Loading
Loading