Skip to content
Open
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
@@ -0,0 +1,14 @@
package com.bencodez.votingplugin.core.vote;

import java.util.Objects;
import java.util.UUID;

public record SharedVoteIdentity(UUID uuid, String playerName, boolean online) {
public SharedVoteIdentity {
Objects.requireNonNull(uuid, "uuid");
Objects.requireNonNull(playerName, "playerName");
if (playerName.isBlank()) {
throw new IllegalArgumentException("playerName cannot be blank");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.bencodez.votingplugin.core.vote;

import java.util.concurrent.CompletionStage;

/** Adapter to the existing AdvancedCore/user identity services. */
@FunctionalInterface
public interface SharedVoteIdentityResolver {
CompletionStage<SharedVoteIdentity> resolve(SharedVoteInput input);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.bencodez.votingplugin.core.vote;

import java.util.Objects;
import java.util.UUID;

/**
* An already-accepted vote entering the shared processing path. Native ingress,
* proxy/global duplicate filtering, service-site validation and security checks
* remain upstream and are intentionally not reimplemented here.
*/
public record SharedVoteInput(UUID voteId, String playerName, String serviceSite, long voteTime,
boolean realVote, boolean addTotals, boolean proxyVote, boolean forceProxyRouting, boolean wasOnline) {
public SharedVoteInput {
Objects.requireNonNull(voteId, "voteId");
Objects.requireNonNull(playerName, "playerName");
Objects.requireNonNull(serviceSite, "serviceSite");
if (playerName.isBlank()) throw new IllegalArgumentException("playerName cannot be blank");
if (serviceSite.isBlank()) throw new IllegalArgumentException("serviceSite cannot be blank");
if (voteTime < 0) throw new IllegalArgumentException("voteTime cannot be negative");
}

/**
* Compatibility constructor for callers that historically had one proxy bit.
* Native adapters should use the full constructor and map isBungee() and
* isForceBungee() independently.
*/
public SharedVoteInput(UUID voteId, String playerName, String serviceSite, long voteTime,
boolean realVote, boolean addTotals, boolean proxyVote, boolean wasOnline) {
this(voteId, playerName, serviceSite, voteTime, realVote, addTotals,
proxyVote, proxyVote, wasOnline);
}

/** PlayerVoteEvent uses zero as "now"; normalize before durable mutation/receipt creation. */
public SharedVoteInput normalizedVoteTime(long nowEpochMillis) {
if (voteTime != 0) return this;
if (nowEpochMillis <= 0) throw new IllegalArgumentException("normalized vote time must be positive");
return new SharedVoteInput(voteId, playerName, serviceSite, nowEpochMillis,
realVote, addTotals, proxyVote, forceProxyRouting, wasOnline);
}

/** A retry carrying the zero sentinel still identifies its already-normalized persisted receipt. */
public boolean matchesPersisted(SharedVoteInput persisted) {
if (persisted == null) return false;
return voteId.equals(persisted.voteId())
&& playerName.equals(persisted.playerName())
&& serviceSite.equals(persisted.serviceSite())
&& (voteTime == 0 || voteTime == persisted.voteTime())
&& realVote == persisted.realVote()
&& addTotals == persisted.addTotals()
&& proxyVote == persisted.proxyVote()
&& forceProxyRouting == persisted.forceProxyRouting()
&& wasOnline == persisted.wasOnline();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.bencodez.votingplugin.core.vote;

import java.util.Objects;
import java.util.UUID;

/**
* Describes the logical vote mutation. The AdvancedCore-facing storage adapter
* owns actual keys, cache/queue ordering, point hooks/caps, and persistence.
*/
public record SharedVoteMutation(UUID voteId, String serviceSite, long voteTime,
boolean countTotals, boolean awardConfiguredPoints) {
public SharedVoteMutation {
Objects.requireNonNull(voteId, "voteId");
Objects.requireNonNull(serviceSite, "serviceSite");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.bencodez.votingplugin.core.vote;

/**
* Platform-neutral subset of the existing vote-processing configuration.
* Native adapters should populate it from the current VotingPlugin config.
*/
public record SharedVotePolicy(boolean countFakeVotes, boolean addTotals,
boolean addTotalsOffline, boolean processRewards, boolean giveOfflineRewards) {

boolean shouldApplyConfiguredVoteMutation(SharedVoteInput input) {
return input.addTotals() && (input.realVote() || countFakeVotes);
}

boolean shouldCountTotals(SharedVoteInput input, boolean online) {
return shouldApplyConfiguredVoteMutation(input) && addTotals && (addTotalsOffline || online);
}

boolean shouldAwardConfiguredPoints(SharedVoteInput input) {
// Existing Bukkit behavior awards configured vote points when the accepted
// vote is countable even if Config.AddTotals itself is disabled.
return shouldApplyConfiguredVoteMutation(input);
}

boolean shouldExecuteRewardsNow(SharedVoteInput input, boolean online) {
// Proxy votes preserve the existing force-processing behavior. For native
// votes, ProcessRewards and per-site offline eligibility remain authoritative.
return input.proxyVote() || (processRewards && (online || giveOfflineRewards));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.bencodez.votingplugin.core.vote;

import java.util.Objects;

public record SharedVoteProcessingResult(SharedVoteIdentity identity, SharedVoteUserSnapshot persistedState,
RewardDisposition rewardDisposition) {
public SharedVoteProcessingResult {
Objects.requireNonNull(identity, "identity");
Objects.requireNonNull(persistedState, "persistedState");
Objects.requireNonNull(rewardDisposition, "rewardDisposition");
}

public enum RewardDisposition {
EXECUTED,
DEFERRED
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package com.bencodez.votingplugin.core.vote;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.function.Supplier;

import com.bencodez.votingplugin.core.vote.SharedVoteProcessingResult.RewardDisposition;

public final class SharedVoteProcessor {
private static final int MAX_RECOVERY_BATCH = 100;
private final SharedVoteIdentityResolver identities;
private final SharedVoteUserServices users;
private final SharedVoteRewardServices rewards;

public SharedVoteProcessor(SharedVoteIdentityResolver identities, SharedVoteUserServices users,
SharedVoteRewardServices rewards) {
this.identities = Objects.requireNonNull(identities, "identities");
this.users = Objects.requireNonNull(users, "users");
this.rewards = Objects.requireNonNull(rewards, "rewards");
}

public CompletionStage<SharedVoteProcessingResult> process(SharedVoteInput input, SharedVotePolicy policy) {
Objects.requireNonNull(input, "input");
Objects.requireNonNull(policy, "policy");
return call(() -> users.findVote(input.voteId()), "receipt lookup").thenCompose(existing -> {
if (existing != null) {
existing.requireInput(input);
return deliver(existing);
}
SharedVoteInput normalized = input.normalizedVoteTime(System.currentTimeMillis());
Comment thread
BenCodez marked this conversation as resolved.
return call(() -> identities.resolve(normalized), "identity resolution").thenCompose(identity -> {
if (identity == null) return failed("Identity resolver returned null identity");
boolean currentOnline = identity.online();
boolean rewardOnline = normalized.proxyVote() ? normalized.wasOnline() : currentOnline;
SharedVoteMutation mutation = new SharedVoteMutation(normalized.voteId(), normalized.serviceSite(),
normalized.voteTime(), policy.shouldCountTotals(normalized, currentOnline),
policy.shouldAwardConfiguredPoints(normalized));
boolean executeNow = policy.shouldExecuteRewardsNow(normalized, rewardOnline);
return call(() -> rewards.prepareVoteRewards(normalized, identity, executeNow), "reward preparation")
.thenCompose(rewardPlan -> {
if (rewardPlan == null) return failed("Reward services returned null prepared reward plan");
// Preserve the ingress sentinel for the atomic uniqueness check. The
// mutation carries this caller's normalized candidate; if another
// concurrent caller wins, the returned receipt is authoritative.
return call(() -> users.persistVoteWithReward(input, identity, mutation, executeNow, rewardPlan),
"atomic persistence").thenCompose(receipt -> {
if (receipt == null) return failed("User services returned null vote receipt");
receipt.requireInput(input);
if (!receipt.identity().uuid().equals(identity.uuid())) {
return failed("Vote receipt belongs to a different resolved identity");
}
return deliver(receipt);
});
});
});
});
}

public CompletionStage<SharedVoteProcessingResult> recover(UUID voteId) {
Objects.requireNonNull(voteId, "voteId");
return call(() -> users.findVote(voteId), "receipt lookup").thenCompose(receipt -> {
if (receipt == null || !voteId.equals(receipt.input().voteId())) return failed("Pending vote receipt was not found");
return deliver(receipt);
});
}

public CompletionStage<List<SharedVoteProcessingResult>> recoverPending(int limit) {
if (limit < 1 || limit > MAX_RECOVERY_BATCH) throw new IllegalArgumentException("Recovery limit must be 1..100");
return call(() -> users.pendingVotes(limit), "pending receipt scan").thenCompose(receipts -> {
if (receipts == null || receipts.size() > limit) return failed("Invalid pending receipt batch");
List<SharedVoteReceipt> batch = List.copyOf(receipts);
HashSet<UUID> ids = new HashSet<>();
for (SharedVoteReceipt receipt : batch) {
if (!receipt.pending() || !ids.add(receipt.input().voteId())) return failed("Invalid pending receipt entry");
}
List<SharedVoteProcessingResult> results = new ArrayList<>();
List<Throwable> failures = new ArrayList<>();
CompletionStage<Void> chain = CompletableFuture.completedFuture(null);
for (SharedVoteReceipt receipt : batch) {
chain = chain.thenCompose(ignored -> deliver(receipt).handle((result, failure) -> {
if (failure == null) results.add(result); else failures.add(failure);
return null;
}));
}
return chain.thenCompose(ignored -> {
if (failures.isEmpty()) return CompletableFuture.completedFuture(List.copyOf(results));
IllegalStateException failure = new IllegalStateException("Some pending vote rewards could not be recovered");
failures.forEach(failure::addSuppressed);
return CompletableFuture.failedFuture(failure);
});
});
}

private CompletionStage<SharedVoteProcessingResult> deliver(SharedVoteReceipt receipt) {
if (!receipt.pending()) return CompletableFuture.completedFuture(result(receipt));
return call(() -> rewards.deliverOnce(receipt), "keyed reward delivery").thenCompose(disposition -> {
if (disposition == null) return failed("Reward services returned null delivery disposition");
return call(() -> users.markRewardCompleted(receipt.input().voteId(), disposition), "reward acknowledgement")
.thenApply(completed -> {
if (completed == null) throw new IllegalStateException("Missing acknowledged vote receipt");
completed.requireSameOrigin(receipt);
if (completed.completedDisposition() != disposition) {
throw new IllegalStateException("Vote reward acknowledgement did not preserve the delivery result");
}
return result(completed);
});
});
}

private static SharedVoteProcessingResult result(SharedVoteReceipt receipt) {
return new SharedVoteProcessingResult(receipt.identity(), receipt.persistedState(), receipt.completedDisposition());
}

private static <T> CompletionStage<T> call(Supplier<CompletionStage<T>> operation, String name) {
try {
CompletionStage<T> stage = operation.get();
return stage == null ? failed("Adapter returned null stage for " + name) : stage;
} catch (Throwable failure) { return CompletableFuture.failedFuture(failure); }
}

private static <T> CompletionStage<T> failed(String message) {
return CompletableFuture.failedFuture(new IllegalStateException(message));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.bencodez.votingplugin.core.vote;

import java.util.Objects;

import com.bencodez.votingplugin.core.vote.SharedVoteProcessingResult.RewardDisposition;

/**
* Immutable receipt keyed by voteId in the existing persistence owner. Its initial
* form is committed atomically with totals/points, pending reward intent and the
* immutable prepared reward version. Across retries only the acknowledgement changes.
*/
public record SharedVoteReceipt(SharedVoteInput input, SharedVoteIdentity identity, SharedVoteMutation mutation,
SharedVoteUserSnapshot persistedState, boolean executeRewardsNow, SharedVoteRewardPlan rewardPlan,
RewardDisposition completedDisposition) {
public SharedVoteReceipt {
Objects.requireNonNull(input, "input");
Objects.requireNonNull(identity, "identity");
Objects.requireNonNull(mutation, "mutation");
Objects.requireNonNull(persistedState, "persistedState");
Objects.requireNonNull(rewardPlan, "rewardPlan");
if (input.voteTime() <= 0) throw new IllegalArgumentException("Persisted vote time must be normalized");
if (!input.voteId().equals(mutation.voteId()) || !input.serviceSite().equals(mutation.serviceSite())
|| input.voteTime() != mutation.voteTime()) {
throw new IllegalArgumentException("Vote receipt input and mutation do not match");
}
}

public boolean pending() { return completedDisposition == null; }

public SharedVoteReceipt completed(RewardDisposition disposition) {
Objects.requireNonNull(disposition, "disposition");
if (!pending() && disposition != completedDisposition) {
throw new IllegalStateException("A completed vote receipt cannot change its reward result");
}
return new SharedVoteReceipt(input, identity, mutation, persistedState, executeRewardsNow, rewardPlan, disposition);
}

public void requireInput(SharedVoteInput expected) {
Objects.requireNonNull(expected, "expected");
if (!expected.matchesPersisted(input)) {
throw new IllegalStateException("voteId is already bound to different vote input");
}
}

public void requireSameOrigin(SharedVoteReceipt expected) {
requireInput(expected.input());
if (!identity.equals(expected.identity()) || !mutation.equals(expected.mutation())
|| !persistedState.equals(expected.persistedState()) || executeRewardsNow != expected.executeRewardsNow()
|| !rewardPlan.equals(expected.rewardPlan())) {
throw new IllegalStateException("Vote receipt changed while acknowledging reward delivery");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.bencodez.votingplugin.core.vote;

import java.util.Objects;

/**
* Immutable reference to the exact reward definition prepared for one vote.
* The version reference must continue resolving to the same archived/snapshotted
* definition after configuration reloads and process restarts.
*/
public record SharedVoteRewardPlan(String planId, String versionReference) {
public SharedVoteRewardPlan {
Objects.requireNonNull(planId, "planId");
Objects.requireNonNull(versionReference, "versionReference");
if (planId.isBlank()) throw new IllegalArgumentException("planId cannot be blank");
if (versionReference.isBlank()) throw new IllegalArgumentException("versionReference cannot be blank");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.bencodez.votingplugin.core.vote;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;

import com.bencodez.votingplugin.core.vote.SharedVoteProcessingResult.RewardDisposition;

/** Adapter for AdvancedCore reward orchestration and its existing durable replay owner. */
public interface SharedVoteRewardServices {
CompletionStage<Void> executeVoteRewards(SharedVoteInput input, SharedVoteIdentity identity,
SharedVoteUserSnapshot persistedState);

CompletionStage<Void> deferVoteRewards(SharedVoteInput input, SharedVoteIdentity identity,
SharedVoteUserSnapshot persistedState);

/**
* Resolve and freeze the exact reward configuration BEFORE the vote transaction.
* versionReference must resolve to this same definition after reload/restart.
*/
default CompletionStage<SharedVoteRewardPlan> prepareVoteRewards(SharedVoteInput input,
SharedVoteIdentity identity, boolean executeRewardsNow) {
return CompletableFuture.failedFuture(new UnsupportedOperationException(
"Shared vote rewards require a persistable prepared reward version"));
}

/**
* Admit/deduplicate by receipt.input().voteId() in the existing reward owner.
* Use receipt.rewardPlan() rather than current configuration. Serialize concurrent
* delivery/recovery for that occurrence, preserve step checkpoints and durably
* remember the terminal disposition. A repeated call resumes pending work or
* returns the same result without redoing completed work.
*/
default CompletionStage<RewardDisposition> deliverOnce(SharedVoteReceipt receipt) {
return CompletableFuture.failedFuture(new UnsupportedOperationException(
"Shared vote rewards require keyed durable replay support"));
}
}
Loading
Loading