Compare commits

..
5 Commits
Author SHA1 Message Date
dmg a82d20c7e6 feat(fixer): add timed combat boost
Release / release (push) Successful in 2m17s
CI / build (push) Successful in 1m2s
2026-08-14 23:11:16 -04:00
dmg 1642d46f1b feat(assassin): add stealth and double jump
Release / release (push) Successful in 2m17s
CI / build (push) Successful in 1m1s
2026-08-14 23:08:18 -04:00
dmg 0371337a3e feat(vigilante): add followers and combat scaling
Release / release (push) Successful in 3m7s
CI / build (push) Successful in 1m15s
2026-08-14 23:01:10 -04:00
dmg 96c0c0014c feat(participation): add opt-out and role status
Release / release (push) Successful in 2m15s
CI / build (push) Successful in 58s
2026-08-14 22:55:43 -04:00
dmg a195658be4 feat(roles): maintain inactive and pending roles
Release / release (push) Successful in 2m19s
CI / build (push) Successful in 1m0s
2026-08-14 22:50:43 -04:00
63 changed files with 2844 additions and 83 deletions
+59
View File
@@ -6,6 +6,65 @@ description: Chronological record of material decisions affecting the Spigot Tyr
# Spigot Tyrant Design Log # Spigot Tyrant Design Log
## 2026-08-14 — Fixer completed
- Completed US-006 with the named owner-bound Fixer's Wrench, one-hour cooldown, and ten-minute Strength and Health Boost activation.
- Activation records a fixed normal two-row or near-Tyrant three-row heart tier using same-world configurable proximity at use time.
- Continuously refreshed effects survive death, login, milk, and ordinary removal; expiry and class loss remove effects and clamp health safely to the resulting maximum.
- Verified activation tiers, active and cooldown deadlines, item consumption/readiness, persistence, and the full Gradle build.
## 2026-08-14 — Fixer implementation started
- US-006 begins with test-first bound-item combat boosts, activation-time proximity tiers, persistent cooldowns, and safe health cleanup.
## 2026-08-14 — Assassin completed
- Completed US-005 with named owner-bound Assassin Cloak activation, one-hour cooldown, particle-free ten-minute invisibility, and activation-time doubling within the Tyrant's configurable range.
- Added 60-second double-jump gating, upward launch, 15-second Speed, 20-second Weakness III, and grounded readiness restoration.
- Active effects and cooldowns persist as UTC deadlines and are continuously restored after death, login, milk, or ordinary removal while valid.
- Verified duration tiers, cooldown consumption and refresh, jump effect deadlines, class constraints, persistence, and the full Gradle build.
## 2026-08-14 — Assassin implementation started
- US-005 begins with test-first bound-item invisibility, near-Tyrant duration doubling, and cooldown-gated double-jump effects.
## 2026-08-14 — Bound ability item implementation started
- US-014 begins with test-first item readiness and recovery, owner metadata, transfer prevention, cooldown redelivery, and Tamer item permanence.
## 2026-08-14 — Vigilante and Followers completed
- Completed US-008 with `/vigilante` invitation, acceptance, dismissal, and leave flows while preserving independent Tyrant-side class assignments.
- Added Tyrant/Vigilante combat tracking and continuously refreshed Strength and Resistance based on living same-world Followers within the configurable 50-block range, capped at Strength V and Resistance IV by default.
- Every Vigilante death now awards progression only to the Tyrant killer, clears Followers, and applies normal recently-active replacement with previous-holder fallback.
- Verified membership, dual alignment, scaling and caps, role maintenance, command metadata, and death/progression components with the Gradle suite.
## 2026-08-14 — Vigilante and Follower implementation started
- US-008 begins with test-first invitation membership, death reroll and Tyrant reward integration, combat tracking, and proximity-scaled capped effects.
## 2026-08-14 — Participation completed
- Completed US-011 with `/tyrant status`, `optout`, `optin`, and confirmed `relinquish` flows plus comprehensive join messages for lifecycle, roles, classes, abilities, and cooldowns.
- Opt-out clears ordinary classes and Follower membership, persists until the player explicitly opts back in, and enforces the configurable seven-day unpaused waiting period.
- Central role holders must safely relinquish first; Tyrant relinquishment performs full succession and Vigilante relinquishment performs a normal Vigilante reroll.
- Updated all assignment and candidate paths to exclude opted-out players and verified status, persistence, cleanup, cooldown, and relinquishment behavior with the Gradle suite.
## 2026-08-14 — Participation implementation started
- US-011 begins with test-first opt-out/opt-in state, seven-day re-entry gating, safe role relinquishment guidance, and complete join status messages.
## 2026-08-14 — Role maintenance completed
- Completed US-010 with configurable periodic role maintenance, exact 48-hour inactivity handling, and 24-hour recently-active candidate filtering.
- Added online confirmation for pending roles, 24-hour expiry and reroll, former-holder fallback rules, vacant-role retries, opt-out filtering, and Tyrant/Vigilante conflict prevention.
- Tyrant inactivity uses full reign succession cleanup; Vigilante inactivity clears only that role's Followers before replacement.
- Verified confirmation, expiry reroll, Follower cleanup, exact inactivity boundary, full Tyrant succession, and pause-safe scheduler integration with the Gradle suite.
## 2026-08-14 — Role maintenance implementation started
- US-010 begins with test-first inactivity replacement, pending confirmation and expiry, conflict-safe candidate retries, and pause awareness.
## 2026-08-14 — Tyrant succession completed ## 2026-08-14 — Tyrant succession completed
- Completed US-002 with player-killer transfer, recent-player environmental succession, former-holder avoidance, pending offline confirmation, and distinct replacement Vigilante selection. - Completed US-002 with player-killer transfer, recent-player environmental succession, former-holder avoidance, pending offline confirmation, and distinct replacement Vigilante selection.
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-005: Use Assassin abilities" title: "US-005: Use Assassin abilities"
description: Give the Assassin timed stealth and a risky burst-mobility ability. description: Give the Assassin timed stealth and a risky burst-mobility ability.
status: backlog status: done
--- ---
# US-005: Use Assassin abilities # US-005: Use Assassin abilities
@@ -11,15 +11,15 @@ As the **Assassin**, I want stealth and burst mobility so that I can ambush oppo
## Acceptance criteria ## Acceptance criteria
- [ ] The Assassin can activate ten minutes of invisibility once per rolling hour using the named Assassin ability item. - [x] The Assassin can activate ten minutes of invisibility once per rolling hour using the named Assassin ability item.
- [ ] Invisibility displays no potion particles. - [x] Invisibility displays no potion particles.
- [ ] Activation while in the same world and within the configured Tyrant range grants twenty minutes instead. - [x] Activation while in the same world and within the configured Tyrant range grants twenty minutes instead.
- [ ] Proximity is evaluated when the ability is activated and does not alter the active duration afterward. - [x] Proximity is evaluated when the ability is activated and does not alter the active duration afterward.
- [ ] The Assassin can double-jump once every 60 seconds without requiring an item. - [x] The Assassin can double-jump once every 60 seconds without requiring an item.
- [ ] A successful double-jump grants Speed for 15 seconds and Weakness III for 20 seconds. - [x] A successful double-jump grants Speed for 15 seconds and Weakness III for 20 seconds.
- [ ] Grounding and supported movement reset jump availability only after the 60-second cooldown has elapsed. - [x] Grounding and supported movement reset jump availability only after the 60-second cooldown has elapsed.
- [ ] Ability cooldowns use unpaused elapsed time and survive death, logout, and restart. - [x] Ability cooldowns use unpaused elapsed time and survive death, logout, and restart.
- [ ] The player can inspect remaining cooldowns and receives clear feedback when an activation is unavailable. - [x] The player can inspect remaining cooldowns and receives clear feedback when an activation is unavailable.
## Related ## Related
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-006: Use Fixer abilities" title: "US-006: Use Fixer abilities"
description: Give the Fixer a periodic offensive and health enhancement strengthened near the Tyrant. description: Give the Fixer a periodic offensive and health enhancement strengthened near the Tyrant.
status: backlog status: done
--- ---
# US-006: Use Fixer abilities # US-006: Use Fixer abilities
@@ -11,13 +11,13 @@ As the **Fixer**, I want a substantial temporary combat boost so that I can prot
## Acceptance criteria ## Acceptance criteria
- [ ] The Fixer can activate ten minutes of Strength and Health Boost once per rolling hour using the named Fixer ability item. - [x] The Fixer can activate ten minutes of Strength and Health Boost once per rolling hour using the named Fixer ability item.
- [ ] Normal activation provides two total rows of hearts when combined with the player's normal maximum health. - [x] Normal activation provides two total rows of hearts when combined with the player's normal maximum health.
- [ ] Activation in the same world and within the configured Tyrant range provides three total rows of hearts. - [x] Activation in the same world and within the configured Tyrant range provides three total rows of hearts.
- [ ] Proximity is evaluated when the ability is activated and does not alter the active tier afterward. - [x] Proximity is evaluated when the ability is activated and does not alter the active tier afterward.
- [ ] Health is safely clamped when the boost expires, is suppressed, or changes tier. - [x] Health is safely clamped when the boost expires, is suppressed, or changes tier.
- [ ] The cooldown uses unpaused elapsed time and survives death, logout, and restart. - [x] The cooldown uses unpaused elapsed time and survives death, logout, and restart.
- [ ] The player can inspect the remaining cooldown and receives clear feedback when activation is unavailable. - [x] The player can inspect the remaining cooldown and receives clear feedback when activation is unavailable.
## Related ## Related
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-008: Support the Vigilante and Followers" title: "US-008: Support the Vigilante and Followers"
description: Give the Vigilante a replaceable role and combat strength derived from nearby Followers. description: Give the Vigilante a replaceable role and combat strength derived from nearby Followers.
status: backlog status: done
--- ---
# US-008: Support the Vigilante and Followers # US-008: Support the Vigilante and Followers
@@ -11,18 +11,18 @@ As the **Vigilante**, I want nearby living Followers to strengthen me against th
## Acceptance criteria ## Acceptance criteria
- [ ] Exactly one active Vigilante exists while the game is running whenever an eligible player is available. - [x] Exactly one active Vigilante exists while the game is running whenever an eligible player is available.
- [ ] The Vigilante can invite eligible opted-in players to become Followers, and recruitment requires acceptance. - [x] The Vigilante can invite eligible opted-in players to become Followers, and recruitment requires acceptance.
- [ ] The Vigilante can dismiss Followers and Followers can leave voluntarily. - [x] The Vigilante can dismiss Followers and Followers can leave voluntarily.
- [ ] While actively fighting the Tyrant, the Vigilante gains one potion level each of Strength and Resistance per living Follower in the same world within 50 blocks. - [x] While actively fighting the Tyrant, the Vigilante gains one potion level each of Strength and Resistance per living Follower in the same world within 50 blocks.
- [ ] Offline, dead, opted-out, dismissed, or out-of-range Followers do not count. - [x] Offline, dead, opted-out, dismissed, or out-of-range Followers do not count.
- [ ] A Vigilante or Follower may simultaneously hold one Tyrant-side class without losing Vigilante-side membership. - [x] A Vigilante or Follower may simultaneously hold one Tyrant-side class without losing Vigilante-side membership.
- [ ] Effect levels update promptly as combat starts or ends and as Followers move, die, respawn, disconnect, or change assignment. - [x] Effect levels update promptly as combat starts or ends and as Followers move, die, respawn, disconnect, or change assignment.
- [ ] Scaling is capped by validated configuration, defaulting to Strength V and Resistance IV. - [x] Scaling is capped by validated configuration, defaulting to Strength V and Resistance IV.
- [ ] Every Vigilante death ends the assignment and triggers random replacement regardless of cause. - [x] Every Vigilante death ends the assignment and triggers random replacement regardless of cause.
- [ ] The deceased Vigilante is excluded when another eligible non-Tyrant candidate exists and is reused only when no such candidate exists. - [x] The deceased Vigilante is excluded when another eligible non-Tyrant candidate exists and is reused only when no such candidate exists.
- [ ] Followers are cleared rather than transferred when their Vigilante dies or loses the position. - [x] Followers are cleared rather than transferred when their Vigilante dies or loses the position.
- [ ] The Tyrant is never eligible to be selected as Vigilante. - [x] The Tyrant is never eligible to be selected as Vigilante.
## Related ## Related
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-010: Handle inactivity and pending selections" title: "US-010: Handle inactivity and pending selections"
description: Replace absent or unconfirmed role holders using recently active eligible players. description: Replace absent or unconfirmed role holders using recently active eligible players.
status: backlog status: done
--- ---
# US-010: Handle inactivity and pending selections # US-010: Handle inactivity and pending selections
@@ -11,16 +11,16 @@ As a **participant**, I want inactive central roles to be replaced fairly so tha
## Acceptance criteria ## Acceptance criteria
- [ ] A Tyrant or Vigilante who has not logged in for 48 unpaused hours loses the position. - [x] A Tyrant or Vigilante who has not logged in for 48 unpaused hours loses the position.
- [ ] Random candidates are drawn from eligible opted-in players who logged in during the preceding 24 hours. - [x] Random candidates are drawn from eligible opted-in players who logged in during the preceding 24 hours.
- [ ] The Tyrant is always excluded from Vigilante selection. - [x] The Tyrant is always excluded from Vigilante selection.
- [ ] A previous role holder is excluded when another eligible candidate exists and may be reused only as a fallback. - [x] A previous role holder is excluded when another eligible candidate exists and may be reused only as a fallback.
- [ ] Tyrant inactivity ends the reign, clears all assignments, and invokes normal random Tyrant succession followed by Vigilante selection. - [x] Tyrant inactivity ends the reign, clears all assignments, and invokes normal random Tyrant succession followed by Vigilante selection.
- [ ] Vigilante inactivity clears Followers and rerolls only the Vigilante position. - [x] Vigilante inactivity clears Followers and rerolls only the Vigilante position.
- [ ] An offline randomly selected candidate must log in within 24 unpaused hours to confirm the assignment. - [x] An offline randomly selected candidate must log in within 24 unpaused hours to confirm the assignment.
- [ ] An unconfirmed selection expires and rerolls automatically under the same eligibility rules. - [x] An unconfirmed selection expires and rerolls automatically under the same eligibility rules.
- [ ] Selection remains vacant and retries periodically when no valid candidate exists. - [x] Selection remains vacant and retries periodically when no valid candidate exists.
- [ ] Candidate selection cannot assign conflicting roles or choose an opted-out player, including during concurrent joins and deaths. - [x] Candidate selection cannot assign conflicting roles or choose an opted-out player, including during concurrent joins and deaths.
## Related ## Related
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-011: Inform players and manage participation" title: "US-011: Inform players and manage participation"
description: Tell players their current place in the game and let unassigned players opt out. description: Tell players their current place in the game and let unassigned players opt out.
status: backlog status: done
--- ---
# US-011: Inform players and manage participation # US-011: Inform players and manage participation
@@ -11,15 +11,15 @@ As a **player**, I want to understand my status and control my eligibility so th
## Acceptance criteria ## Acceptance criteria
- [ ] On join, a player is told the game status and their role, class, subclass, abilities, and relevant cooldowns. - [x] On join, a player is told the game status and their role, class, subclass, abilities, and relevant cooldowns.
- [ ] An unassigned opted-in player is told that they are eligible and shown the opt-out command. - [x] An unassigned opted-in player is told that they are eligible and shown the opt-out command.
- [ ] An opted-out player is told when they may opt back in. - [x] An opted-out player is told when they may opt back in.
- [ ] An unassigned player can opt out through a command. - [x] An unassigned player can opt out through a command.
- [ ] Opting out removes an ordinary class or Follower assignment immediately; the Tyrant and Vigilante must relinquish through a safe role command before opting out. - [x] Opting out removes an ordinary class or Follower assignment immediately; the Tyrant and Vigilante must relinquish through a safe role command before opting out.
- [ ] Opted-out players cannot be randomly selected, recruited, or assigned a class. - [x] Opted-out players cannot be randomly selected, recruited, or assigned a class.
- [ ] A player cannot opt back in until seven days have elapsed by default. - [x] A player cannot opt back in until seven days have elapsed by default.
- [ ] The opt-out duration is configurable, uses unpaused elapsed time, and survives restart. - [x] The opt-out duration is configurable, uses unpaused elapsed time, and survives restart.
- [ ] Participation commands are idempotent and clearly report current status and remaining time. - [x] Participation commands are idempotent and clearly report current status and remaining time.
## Related ## Related
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-014: Use class ability items" title: "US-014: Use class ability items"
description: Give class holders named bound items that activate abilities without enabling transfer or cooldown bypasses. description: Give class holders named bound items that activate abilities without enabling transfer or cooldown bypasses.
status: backlog status: in-progress
--- ---
# US-014: Use class ability items # US-014: Use class ability items
@@ -2,8 +2,13 @@ package games.dmg.spigottyrant;
public enum Ability { public enum Ability {
ASSASSIN_INVISIBILITY, ASSASSIN_INVISIBILITY,
ASSASSIN_INVISIBILITY_ACTIVE,
ASSASSIN_DOUBLE_JUMP, ASSASSIN_DOUBLE_JUMP,
ASSASSIN_SPEED_ACTIVE,
ASSASSIN_WEAKNESS_ACTIVE,
FIXER_BOOST, FIXER_BOOST,
FIXER_BOOST_ACTIVE,
FIXER_NEAR_TYRANT_ACTIVE,
TAMER_CAPTURE, TAMER_CAPTURE,
ROSTER_INTELLIGENCE ROSTER_INTELLIGENCE
} }
@@ -0,0 +1,45 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import org.bukkit.Server;
import org.bukkit.entity.Player;
public final class AbilityItemRefreshTask implements Runnable {
private final TyrantStateManager stateManager;
private final AbilityReadinessService readiness;
private final AbilityItemService items;
private final Server server;
private final Clock clock;
public AbilityItemRefreshTask(
TyrantStateManager stateManager,
AbilityReadinessService readiness,
AbilityItemService items,
Server server,
Clock clock
) {
this.stateManager = stateManager;
this.readiness = readiness;
this.items = items;
this.server = server;
this.clock = clock;
}
@Override
public void run() {
if (stateManager.game().lifecycle() != GameLifecycle.RUNNING) {
return;
}
for (Player player : server.getOnlinePlayers()) {
PlayerState before = stateManager.player(player.getUniqueId(), player.getName());
PlayerState after = readiness.refresh(before, clock.instant());
if (!after.equals(before)) {
stateManager.updatePlayer(
player.getUniqueId(), player.getName(), current -> after
);
}
items.removeInvalid(player, after);
items.recover(player, after);
}
}
}
@@ -0,0 +1,20 @@
package games.dmg.spigottyrant;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
public interface AbilityItemService {
void recover(Player player, PlayerState state);
boolean isBoundAbilityItem(ItemStack item);
Optional<Ability> ability(ItemStack item);
Optional<UUID> owner(ItemStack item);
void removeInvalid(Player player, PlayerState state);
void removeAll(Player player);
}
@@ -0,0 +1,68 @@
package games.dmg.spigottyrant;
import java.time.Duration;
import java.time.Instant;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.Map;
import java.util.Set;
public final class AbilityReadinessService {
public PlayerState consume(
PlayerState player,
Ability ability,
Instant now,
Duration cooldown
) {
if (!player.readyAbilityItems().contains(ability)) {
throw new IllegalStateException("ability item is not ready");
}
if (ability == Ability.TAMER_CAPTURE && cooldown.isZero()) {
return player;
}
Set<Ability> ready = EnumSet.noneOf(Ability.class);
ready.addAll(player.readyAbilityItems());
ready.remove(ability);
Map<Ability, Instant> cooldowns = new EnumMap<>(Ability.class);
cooldowns.putAll(player.cooldownEnds());
cooldowns.put(ability, now.plus(cooldown));
return copy(player, cooldowns, ready);
}
public PlayerState refresh(PlayerState player, Instant now) {
Map<Ability, Instant> cooldowns = new EnumMap<>(Ability.class);
cooldowns.putAll(player.cooldownEnds());
Set<Ability> ready = EnumSet.noneOf(Ability.class);
ready.addAll(player.readyAbilityItems());
for (Map.Entry<Ability, Instant> entry : player.cooldownEnds().entrySet()) {
if (!entry.getValue().isAfter(now)) {
cooldowns.remove(entry.getKey());
if (isItemAbilityForClass(entry.getKey(), player.tyrantClass())) {
ready.add(entry.getKey());
}
}
}
return copy(player, cooldowns, ready);
}
private static boolean isItemAbilityForClass(Ability ability, TyrantClass tyrantClass) {
return switch (tyrantClass) {
case ASSASSIN -> ability == Ability.ASSASSIN_INVISIBILITY;
case FIXER -> ability == Ability.FIXER_BOOST;
case TAMER -> ability == Ability.TAMER_CAPTURE;
case NONE -> false;
};
}
private static PlayerState copy(
PlayerState player,
Map<Ability, Instant> cooldowns,
Set<Ability> ready
) {
return new PlayerState(
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
player.tyrantClass(), player.followerOf(), cooldowns, ready,
player.capturedMobs()
);
}
}
@@ -0,0 +1,4 @@
package games.dmg.spigottyrant;
public record AbilityUseResult(PlayerState player, AbilityUseStatus status) {
}
@@ -0,0 +1,10 @@
package games.dmg.spigottyrant;
public enum AbilityUseStatus {
ACTIVATED,
WRONG_CLASS,
NOT_READY,
COOLDOWN,
GAME_NOT_RUNNING,
WRONG_OWNER
}
@@ -0,0 +1,88 @@
package games.dmg.spigottyrant;
import java.time.Instant;
import java.util.EnumMap;
import java.util.Map;
public final class AssassinAbilityService {
private final AbilityReadinessService readiness;
private final PluginSettings settings;
public AssassinAbilityService(
AbilityReadinessService readiness,
PluginSettings settings
) {
this.readiness = readiness;
this.settings = settings;
}
public AbilityUseResult activateInvisibility(
PlayerState player,
Instant now,
boolean nearTyrant
) {
if (player.tyrantClass() != TyrantClass.ASSASSIN) {
return new AbilityUseResult(player, AbilityUseStatus.WRONG_CLASS);
}
if (!player.readyAbilityItems().contains(Ability.ASSASSIN_INVISIBILITY)) {
return new AbilityUseResult(player, AbilityUseStatus.NOT_READY);
}
PlayerState used = readiness.consume(
player, Ability.ASSASSIN_INVISIBILITY, now, settings.assassinCooldown()
);
Instant activeUntil = now.plus(
nearTyrant
? settings.assassinInvisibilityDuration().multipliedBy(2)
: settings.assassinInvisibilityDuration()
);
return new AbilityUseResult(
withDeadline(used, Ability.ASSASSIN_INVISIBILITY_ACTIVE, activeUntil),
AbilityUseStatus.ACTIVATED
);
}
public AbilityUseResult doubleJump(PlayerState player, Instant now) {
if (player.tyrantClass() != TyrantClass.ASSASSIN) {
return new AbilityUseResult(player, AbilityUseStatus.WRONG_CLASS);
}
if (player.cooldownEnds().getOrDefault(
Ability.ASSASSIN_DOUBLE_JUMP, Instant.MIN
).isAfter(now)) {
return new AbilityUseResult(player, AbilityUseStatus.COOLDOWN);
}
Map<Ability, Instant> deadlines = new EnumMap<>(Ability.class);
deadlines.putAll(player.cooldownEnds());
deadlines.put(
Ability.ASSASSIN_DOUBLE_JUMP,
now.plus(settings.assassinDoubleJumpCooldown())
);
deadlines.put(
Ability.ASSASSIN_SPEED_ACTIVE,
now.plus(settings.assassinSpeedDuration())
);
deadlines.put(
Ability.ASSASSIN_WEAKNESS_ACTIVE,
now.plus(settings.assassinWeaknessDuration())
);
return new AbilityUseResult(copy(player, deadlines), AbilityUseStatus.ACTIVATED);
}
private static PlayerState withDeadline(
PlayerState player,
Ability ability,
Instant deadline
) {
Map<Ability, Instant> deadlines = new EnumMap<>(Ability.class);
deadlines.putAll(player.cooldownEnds());
deadlines.put(ability, deadline);
return copy(player, deadlines);
}
private static PlayerState copy(PlayerState player, Map<Ability, Instant> deadlines) {
return new PlayerState(
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
player.tyrantClass(), player.followerOf(), deadlines,
player.readyAbilityItems(), player.capturedMobs()
);
}
}
@@ -0,0 +1,106 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.time.Instant;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import org.bukkit.GameMode;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
public final class AssassinEffectController implements Runnable {
private static final int EFFECT_TICKS = 30;
private final TyrantStateManager stateManager;
private final Server server;
private final PluginSettings settings;
private final Clock clock;
private final Set<UUID> grantedFlight = new HashSet<>();
public AssassinEffectController(
TyrantStateManager stateManager,
Server server,
PluginSettings settings,
Clock clock
) {
this.stateManager = stateManager;
this.server = server;
this.settings = settings;
this.clock = clock;
}
@Override
public void run() {
Instant now = clock.instant();
for (Player player : server.getOnlinePlayers()) {
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (stateManager.game().lifecycle() != GameLifecycle.RUNNING
|| state.tyrantClass() != TyrantClass.ASSASSIN) {
clear(player);
continue;
}
applyWhileActive(
player, state, Ability.ASSASSIN_INVISIBILITY_ACTIVE,
PotionEffectType.INVISIBILITY, 0, false
);
applyWhileActive(
player, state, Ability.ASSASSIN_SPEED_ACTIVE,
PotionEffectType.SPEED, 0, true
);
applyWhileActive(
player, state, Ability.ASSASSIN_WEAKNESS_ACTIVE,
PotionEffectType.WEAKNESS, settings.assassinWeaknessLevel() - 1, true
);
boolean jumpReady = !state.cooldownEnds().getOrDefault(
Ability.ASSASSIN_DOUBLE_JUMP, Instant.MIN
).isAfter(now);
if (jumpReady && isGrounded(player) && isSurvivalLike(player)) {
player.setAllowFlight(true);
grantedFlight.add(player.getUniqueId());
}
}
}
public void clearAll() {
server.getOnlinePlayers().forEach(this::clear);
}
private void applyWhileActive(
Player player,
PlayerState state,
Ability ability,
PotionEffectType type,
int amplifier,
boolean particles
) {
if (state.cooldownEnds().getOrDefault(ability, Instant.MIN).isAfter(clock.instant())) {
player.addPotionEffect(new PotionEffect(
type, EFFECT_TICKS, amplifier, false, particles, true
));
} else {
player.removePotionEffect(type);
}
}
private void clear(Player player) {
player.removePotionEffect(PotionEffectType.INVISIBILITY);
player.removePotionEffect(PotionEffectType.SPEED);
player.removePotionEffect(PotionEffectType.WEAKNESS);
if (grantedFlight.remove(player.getUniqueId()) && isSurvivalLike(player)) {
player.setAllowFlight(false);
player.setFlying(false);
}
}
private static boolean isGrounded(Player player) {
return !player.getLocation().clone().subtract(0.0, 0.1, 0.0)
.getBlock().isPassable();
}
private static boolean isSurvivalLike(Player player) {
return player.getGameMode() != GameMode.CREATIVE
&& player.getGameMode() != GameMode.SPECTATOR;
}
}
@@ -0,0 +1,79 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
public final class AssassinItemListener implements Listener {
private final TyrantStateManager stateManager;
private final AbilityItemService items;
private final AssassinAbilityService abilities;
private final PluginSettings settings;
private final Clock clock;
public AssassinItemListener(
TyrantStateManager stateManager,
AbilityItemService items,
AssassinAbilityService abilities,
PluginSettings settings,
Clock clock
) {
this.stateManager = stateManager;
this.items = items;
this.abilities = abilities;
this.settings = settings;
this.clock = clock;
}
@EventHandler(priority = EventPriority.HIGH)
public void onInteract(PlayerInteractEvent event) {
Action action = event.getAction();
if (action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK
&& action != Action.LEFT_CLICK_AIR && action != Action.LEFT_CLICK_BLOCK) {
return;
}
if (items.ability(event.getItem())
.filter(Ability.ASSASSIN_INVISIBILITY::equals).isEmpty()) {
return;
}
event.setCancelled(true);
UUID playerId = event.getPlayer().getUniqueId();
if (items.owner(event.getItem()).filter(playerId::equals).isEmpty()) {
event.getPlayer().getInventory().setItem(event.getHand(), null);
event.getPlayer().sendMessage(ChatColor.RED + "That ability item is not yours.");
return;
}
if (stateManager.game().lifecycle() != GameLifecycle.RUNNING) {
event.getPlayer().sendMessage(ChatColor.RED + "The Tyrant game is not running.");
return;
}
PlayerState player = stateManager.player(playerId, event.getPlayer().getName());
AbilityUseResult result = abilities.activateInvisibility(
player, clock.instant(), isNearTyrant(event.getPlayer())
);
if (result.status() == AbilityUseStatus.ACTIVATED) {
stateManager.updatePlayer(playerId, event.getPlayer().getName(), current -> result.player());
stateManager.saveIfDirty();
event.getPlayer().getInventory().setItem(event.getHand(), null);
event.getPlayer().sendMessage(ChatColor.GREEN + "Invisibility activated.");
} else {
event.getPlayer().sendMessage(ChatColor.RED + "Ability unavailable: "
+ result.status().name().toLowerCase(java.util.Locale.ROOT) + ".");
}
}
private boolean isNearTyrant(org.bukkit.entity.Player player) {
Optional<org.bukkit.entity.Player> tyrant = stateManager.game().tyrantId()
.map(org.bukkit.Bukkit::getPlayer);
return tyrant.isPresent()
&& tyrant.orElseThrow().getWorld().equals(player.getWorld())
&& tyrant.orElseThrow().getLocation().distanceSquared(player.getLocation())
<= settings.tyrantRangeBlocks() * settings.tyrantRangeBlocks();
}
}
@@ -0,0 +1,55 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import org.bukkit.ChatColor;
import org.bukkit.GameMode;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerToggleFlightEvent;
import org.bukkit.util.Vector;
public final class AssassinJumpListener implements Listener {
private final TyrantStateManager stateManager;
private final AssassinAbilityService abilities;
private final Clock clock;
public AssassinJumpListener(
TyrantStateManager stateManager,
AssassinAbilityService abilities,
Clock clock
) {
this.stateManager = stateManager;
this.abilities = abilities;
this.clock = clock;
}
@EventHandler
public void onToggleFlight(PlayerToggleFlightEvent event) {
if (event.getPlayer().getGameMode() == GameMode.CREATIVE
|| event.getPlayer().getGameMode() == GameMode.SPECTATOR) {
return;
}
PlayerState state = stateManager.player(
event.getPlayer().getUniqueId(), event.getPlayer().getName()
);
if (stateManager.game().lifecycle() != GameLifecycle.RUNNING
|| state.tyrantClass() != TyrantClass.ASSASSIN) {
return;
}
event.setCancelled(true);
event.getPlayer().setFlying(false);
event.getPlayer().setAllowFlight(false);
AbilityUseResult result = abilities.doubleJump(state, clock.instant());
if (result.status() != AbilityUseStatus.ACTIVATED) {
event.getPlayer().sendMessage(ChatColor.RED + "Double jump is cooling down.");
return;
}
stateManager.updatePlayer(
event.getPlayer().getUniqueId(), event.getPlayer().getName(),
current -> result.player()
);
stateManager.saveIfDirty();
Vector velocity = event.getPlayer().getVelocity();
event.getPlayer().setVelocity(velocity.setY(Math.max(0.8, velocity.getY())));
}
}
@@ -0,0 +1,82 @@
package games.dmg.spigottyrant;
import org.bukkit.entity.ItemFrame;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockDispenseEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.entity.EntityPickupItemEvent;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.player.PlayerDropItemEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.inventory.ItemStack;
public final class BoundItemListener implements Listener {
private final AbilityItemService items;
public BoundItemListener(AbilityItemService items) {
this.items = items;
}
@EventHandler
public void onDrop(PlayerDropItemEvent event) {
if (items.isBoundAbilityItem(event.getItemDrop().getItemStack())) {
event.getItemDrop().remove();
}
}
@EventHandler
public void onPickup(EntityPickupItemEvent event) {
if (!(event.getEntity() instanceof Player player)) {
return;
}
ItemStack item = event.getItem().getItemStack();
if (items.isBoundAbilityItem(item)
&& items.owner(item).filter(player.getUniqueId()::equals).isEmpty()) {
event.setCancelled(true);
event.getItem().remove();
}
}
@EventHandler
public void onInventoryClick(InventoryClickEvent event) {
if (items.isBoundAbilityItem(event.getCurrentItem())
|| items.isBoundAbilityItem(event.getCursor())) {
if (!(event.getWhoClicked() instanceof Player player)
|| event.getClickedInventory() == null
|| !event.getClickedInventory().equals(player.getInventory())) {
event.setCancelled(true);
}
}
}
@EventHandler
public void onPlace(BlockPlaceEvent event) {
if (items.isBoundAbilityItem(event.getItemInHand())) {
event.setCancelled(true);
}
}
@EventHandler
public void onDispense(BlockDispenseEvent event) {
if (items.isBoundAbilityItem(event.getItem())) {
event.setCancelled(true);
}
}
@EventHandler
public void onFrame(PlayerInteractEntityEvent event) {
if (event.getRightClicked() instanceof ItemFrame
&& items.isBoundAbilityItem(event.getPlayer().getInventory()
.getItem(event.getHand()))) {
event.setCancelled(true);
}
}
@EventHandler
public void onDeath(PlayerDeathEvent event) {
event.getDrops().removeIf(items::isBoundAbilityItem);
}
}
@@ -0,0 +1,152 @@
package games.dmg.spigottyrant;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.plugin.Plugin;
public final class BukkitAbilityItemService implements AbilityItemService {
private final NamespacedKey abilityKey;
private final NamespacedKey ownerKey;
private final PluginSettings settings;
public BukkitAbilityItemService(Plugin plugin, PluginSettings settings) {
abilityKey = new NamespacedKey(plugin, "ability");
ownerKey = new NamespacedKey(plugin, "owner");
this.settings = settings;
}
@Override
public void recover(Player player, PlayerState state) {
for (Ability ability : state.readyAbilityItems()) {
if (!isItemAbilityForClass(ability, state.tyrantClass())
|| contains(player, state.playerId(), ability)) {
continue;
}
ItemStack item = create(state.playerId(), ability);
Map<Integer, ItemStack> leftovers = player.getInventory().addItem(item);
if (!leftovers.isEmpty()) {
player.sendMessage(ChatColor.RED + settings.inventoryFullMessage());
}
}
}
@Override
public boolean isBoundAbilityItem(ItemStack item) {
return ability(item).isPresent() && owner(item).isPresent();
}
@Override
public Optional<Ability> ability(ItemStack item) {
if (item == null || !item.hasItemMeta()) {
return Optional.empty();
}
String value = item.getItemMeta().getPersistentDataContainer()
.get(abilityKey, PersistentDataType.STRING);
if (value == null) {
return Optional.empty();
}
try {
return Optional.of(Ability.valueOf(value));
} catch (IllegalArgumentException exception) {
return Optional.empty();
}
}
@Override
public Optional<UUID> owner(ItemStack item) {
if (item == null || !item.hasItemMeta()) {
return Optional.empty();
}
String value = item.getItemMeta().getPersistentDataContainer()
.get(ownerKey, PersistentDataType.STRING);
if (value == null) {
return Optional.empty();
}
try {
return Optional.of(UUID.fromString(value));
} catch (IllegalArgumentException exception) {
return Optional.empty();
}
}
@Override
public void removeInvalid(Player player, PlayerState state) {
ItemStack[] contents = player.getInventory().getContents();
for (int index = 0; index < contents.length; index++) {
ItemStack item = contents[index];
if (isBoundAbilityItem(item)
&& (owner(item).filter(state.playerId()::equals).isEmpty()
|| ability(item).filter(state.readyAbilityItems()::contains).isEmpty())) {
player.getInventory().setItem(index, null);
}
}
}
@Override
public void removeAll(Player player) {
ItemStack[] contents = player.getInventory().getContents();
for (int index = 0; index < contents.length; index++) {
if (isBoundAbilityItem(contents[index])) {
player.getInventory().setItem(index, null);
}
}
}
private ItemStack create(UUID ownerId, Ability ability) {
PluginSettings.AbilityItemSettings configured = settingsFor(ability);
Material material = Material.matchMaterial(configured.material());
if (material == null) {
throw new IllegalStateException("Configured item material is unavailable");
}
ItemStack item = new ItemStack(material);
ItemMeta meta = item.getItemMeta();
meta.setDisplayName(ChatColor.GOLD + configured.name());
meta.getPersistentDataContainer().set(
abilityKey, PersistentDataType.STRING, ability.name()
);
meta.getPersistentDataContainer().set(
ownerKey, PersistentDataType.STRING, ownerId.toString()
);
item.setItemMeta(meta);
return item;
}
private boolean contains(Player player, UUID ownerId, Ability ability) {
for (ItemStack item : player.getInventory().getContents()) {
if (owner(item).filter(ownerId::equals).isPresent()
&& ability(item).filter(ability::equals).isPresent()) {
return true;
}
}
return false;
}
private PluginSettings.AbilityItemSettings settingsFor(Ability ability) {
return switch (ability) {
case ASSASSIN_INVISIBILITY -> settings.assassinItem();
case FIXER_BOOST -> settings.fixerItem();
case TAMER_CAPTURE -> settings.tamerItem();
default -> throw new IllegalArgumentException(
ability.name().toLowerCase(Locale.ROOT) + " has no class item"
);
};
}
private static boolean isItemAbilityForClass(Ability ability, TyrantClass tyrantClass) {
return switch (tyrantClass) {
case ASSASSIN -> ability == Ability.ASSASSIN_INVISIBILITY;
case FIXER -> ability == Ability.FIXER_BOOST;
case TAMER -> ability == Ability.TAMER_CAPTURE;
case NONE -> false;
};
}
}
@@ -14,4 +14,11 @@ public final class BukkitOnlinePlayerDirectory implements OnlinePlayerDirectory
public Player findById(UUID playerId) { public Player findById(UUID playerId) {
return Bukkit.getPlayer(playerId); return Bukkit.getPlayer(playerId);
} }
@Override
public java.util.Set<UUID> onlinePlayerIds() {
return Bukkit.getOnlinePlayers().stream()
.map(Player::getUniqueId)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
}
} }
@@ -78,7 +78,7 @@ public final class ClassAssignmentService {
if (targetId.equals(assignerId)) { if (targetId.equals(assignerId)) {
return ClassAssignmentStatus.TARGET_IS_TYRANT; return ClassAssignmentStatus.TARGET_IS_TYRANT;
} }
if (target.optedOutUntil().filter(until -> until.isAfter(now)).isPresent()) { if (target.optedOutUntil().isPresent()) {
return ClassAssignmentStatus.TARGET_OPTED_OUT; return ClassAssignmentStatus.TARGET_OPTED_OUT;
} }
return null; return null;
@@ -102,6 +102,7 @@ public final class ClassAssignmentService {
cooldowns.remove(ability); cooldowns.remove(ability);
readyItems.remove(ability); readyItems.remove(ability);
} }
readyItemFor(tyrantClass).ifPresent(readyItems::add);
return new PlayerState( return new PlayerState(
player.playerId(), player.playerId(),
player.latestName(), player.latestName(),
@@ -115,6 +116,15 @@ public final class ClassAssignmentService {
); );
} }
private static Optional<Ability> readyItemFor(TyrantClass tyrantClass) {
return switch (tyrantClass) {
case ASSASSIN -> Optional.of(Ability.ASSASSIN_INVISIBILITY);
case FIXER -> Optional.of(Ability.FIXER_BOOST);
case TAMER -> Optional.of(Ability.TAMER_CAPTURE);
case NONE -> Optional.empty();
};
}
private static Set<Ability> abilitiesFor(TyrantClass tyrantClass) { private static Set<Ability> abilitiesFor(TyrantClass tyrantClass) {
return switch (tyrantClass) { return switch (tyrantClass) {
case ASSASSIN -> Set.of( case ASSASSIN -> Set.of(
@@ -0,0 +1,45 @@
package games.dmg.spigottyrant;
import java.time.Instant;
import java.util.EnumMap;
import java.util.Map;
public final class FixerAbilityService {
private final AbilityReadinessService readiness;
private final PluginSettings settings;
public FixerAbilityService(
AbilityReadinessService readiness,
PluginSettings settings
) {
this.readiness = readiness;
this.settings = settings;
}
public FixerUseResult activate(PlayerState player, Instant now, boolean nearTyrant) {
if (player.tyrantClass() != TyrantClass.FIXER) {
return new FixerUseResult(player, AbilityUseStatus.WRONG_CLASS, 0);
}
if (!player.readyAbilityItems().contains(Ability.FIXER_BOOST)) {
return new FixerUseResult(player, AbilityUseStatus.NOT_READY, 0);
}
PlayerState used = readiness.consume(
player, Ability.FIXER_BOOST, now, settings.fixerCooldown()
);
Instant activeUntil = now.plus(settings.fixerEffectDuration());
Map<Ability, Instant> deadlines = new EnumMap<>(Ability.class);
deadlines.putAll(used.cooldownEnds());
deadlines.put(Ability.FIXER_BOOST_ACTIVE, activeUntil);
if (nearTyrant) {
deadlines.put(Ability.FIXER_NEAR_TYRANT_ACTIVE, activeUntil);
}
PlayerState updated = new PlayerState(
used.playerId(), used.latestName(), used.lastLogin(), used.optedOutUntil(),
used.tyrantClass(), used.followerOf(), deadlines, used.readyAbilityItems(),
used.capturedMobs()
);
int rows = nearTyrant
? settings.fixerNearTyrantHeartRows() : settings.fixerNormalHeartRows();
return new FixerUseResult(updated, AbilityUseStatus.ACTIVATED, rows);
}
}
@@ -0,0 +1,74 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.time.Instant;
import org.bukkit.Server;
import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance;
import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
public final class FixerEffectController implements Runnable {
private static final int EFFECT_TICKS = 30;
private final TyrantStateManager stateManager;
private final Server server;
private final PluginSettings settings;
private final Clock clock;
public FixerEffectController(
TyrantStateManager stateManager,
Server server,
PluginSettings settings,
Clock clock
) {
this.stateManager = stateManager;
this.server = server;
this.settings = settings;
this.clock = clock;
}
@Override
public void run() {
Instant now = clock.instant();
for (Player player : server.getOnlinePlayers()) {
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
boolean active = stateManager.game().lifecycle() == GameLifecycle.RUNNING
&& state.tyrantClass() == TyrantClass.FIXER
&& state.cooldownEnds().getOrDefault(
Ability.FIXER_BOOST_ACTIVE, Instant.MIN
).isAfter(now);
if (!active) {
clear(player);
continue;
}
boolean nearTier = state.cooldownEnds().getOrDefault(
Ability.FIXER_NEAR_TYRANT_ACTIVE, Instant.MIN
).isAfter(now);
int rows = nearTier
? settings.fixerNearTyrantHeartRows() : settings.fixerNormalHeartRows();
int healthBoostAmplifier = (rows - 1) * 5 - 1;
player.addPotionEffect(new PotionEffect(
PotionEffectType.STRENGTH, EFFECT_TICKS,
settings.fixerStrengthLevel() - 1, false, true, true
));
player.addPotionEffect(new PotionEffect(
PotionEffectType.HEALTH_BOOST, EFFECT_TICKS,
healthBoostAmplifier, false, true, true
));
}
}
public void clearAll() {
server.getOnlinePlayers().forEach(FixerEffectController::clear);
}
private static void clear(Player player) {
player.removePotionEffect(PotionEffectType.STRENGTH);
player.removePotionEffect(PotionEffectType.HEALTH_BOOST);
AttributeInstance maximumHealth = player.getAttribute(Attribute.MAX_HEALTH);
if (maximumHealth != null && player.getHealth() > maximumHealth.getValue()) {
player.setHealth(maximumHealth.getValue());
}
}
}
@@ -0,0 +1,79 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
public final class FixerItemListener implements Listener {
private final TyrantStateManager stateManager;
private final AbilityItemService items;
private final FixerAbilityService abilities;
private final PluginSettings settings;
private final Clock clock;
public FixerItemListener(
TyrantStateManager stateManager,
AbilityItemService items,
FixerAbilityService abilities,
PluginSettings settings,
Clock clock
) {
this.stateManager = stateManager;
this.items = items;
this.abilities = abilities;
this.settings = settings;
this.clock = clock;
}
@EventHandler(priority = EventPriority.HIGH)
public void onInteract(PlayerInteractEvent event) {
Action action = event.getAction();
if (action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK
&& action != Action.LEFT_CLICK_AIR && action != Action.LEFT_CLICK_BLOCK) {
return;
}
if (items.ability(event.getItem()).filter(Ability.FIXER_BOOST::equals).isEmpty()) {
return;
}
event.setCancelled(true);
UUID playerId = event.getPlayer().getUniqueId();
if (items.owner(event.getItem()).filter(playerId::equals).isEmpty()) {
event.getPlayer().getInventory().setItem(event.getHand(), null);
event.getPlayer().sendMessage(ChatColor.RED + "That ability item is not yours.");
return;
}
if (stateManager.game().lifecycle() != GameLifecycle.RUNNING) {
event.getPlayer().sendMessage(ChatColor.RED + "The Tyrant game is not running.");
return;
}
PlayerState player = stateManager.player(playerId, event.getPlayer().getName());
FixerUseResult result = abilities.activate(
player, clock.instant(), isNearTyrant(event.getPlayer())
);
if (result.status() == AbilityUseStatus.ACTIVATED) {
stateManager.updatePlayer(playerId, event.getPlayer().getName(), current -> result.player());
stateManager.saveIfDirty();
event.getPlayer().getInventory().setItem(event.getHand(), null);
event.getPlayer().sendMessage(ChatColor.GREEN + "Fixer boost activated with "
+ result.heartRows() + " rows of hearts.");
} else {
event.getPlayer().sendMessage(ChatColor.RED + "Ability unavailable: "
+ result.status().name().toLowerCase(java.util.Locale.ROOT) + ".");
}
}
private boolean isNearTyrant(org.bukkit.entity.Player player) {
Optional<org.bukkit.entity.Player> tyrant = stateManager.game().tyrantId()
.map(org.bukkit.Bukkit::getPlayer);
return tyrant.isPresent()
&& tyrant.orElseThrow().getWorld().equals(player.getWorld())
&& tyrant.orElseThrow().getLocation().distanceSquared(player.getLocation())
<= settings.tyrantRangeBlocks() * settings.tyrantRangeBlocks();
}
}
@@ -0,0 +1,8 @@
package games.dmg.spigottyrant;
public record FixerUseResult(
PlayerState player,
AbilityUseStatus status,
int heartRows
) {
}
@@ -0,0 +1,10 @@
package games.dmg.spigottyrant;
import java.util.Map;
import java.util.UUID;
public record FollowerResult(Map<UUID, PlayerState> players, FollowerStatus status) {
public FollowerResult {
players = Map.copyOf(players);
}
}
@@ -0,0 +1,111 @@
package games.dmg.spigottyrant;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
public final class FollowerService {
private final Map<UUID, UUID> invitations = new HashMap<>();
public FollowerResult invite(
GameState game,
Map<UUID, PlayerState> players,
UUID vigilanteId,
UUID targetId
) {
if (game.lifecycle() != GameLifecycle.RUNNING) {
return result(players, FollowerStatus.GAME_NOT_RUNNING);
}
if (game.vigilanteId().filter(vigilanteId::equals).isEmpty()) {
return result(players, FollowerStatus.NOT_VIGILANTE);
}
PlayerState target = players.get(targetId);
if (target == null || target.optedOutUntil().isPresent()
|| game.tyrantId().filter(targetId::equals).isPresent()
|| game.vigilanteId().filter(targetId::equals).isPresent()) {
return result(players, FollowerStatus.INELIGIBLE);
}
invitations.put(targetId, vigilanteId);
return result(players, FollowerStatus.INVITED);
}
public FollowerResult accept(
GameState game,
Map<UUID, PlayerState> players,
UUID targetId
) {
UUID invitedBy = invitations.get(targetId);
if (invitedBy == null
|| game.vigilanteId().filter(invitedBy::equals).isEmpty()) {
return result(players, FollowerStatus.NO_INVITATION);
}
PlayerState target = players.get(targetId);
if (target == null || target.optedOutUntil().isPresent()) {
invitations.remove(targetId);
return result(players, FollowerStatus.INELIGIBLE);
}
invitations.remove(targetId);
return updated(players, withFollower(target, Optional.of(invitedBy)), FollowerStatus.JOINED);
}
public FollowerResult dismiss(
GameState game,
Map<UUID, PlayerState> players,
UUID vigilanteId,
UUID targetId
) {
if (game.vigilanteId().filter(vigilanteId::equals).isEmpty()) {
return result(players, FollowerStatus.NOT_VIGILANTE);
}
PlayerState target = players.get(targetId);
if (target == null || target.followerOf().filter(vigilanteId::equals).isEmpty()) {
return result(players, FollowerStatus.NOT_FOLLOWER);
}
return updated(players, withFollower(target, Optional.empty()), FollowerStatus.DISMISSED);
}
public FollowerResult leave(
GameState game,
Map<UUID, PlayerState> players,
UUID targetId
) {
PlayerState target = players.get(targetId);
if (target == null || target.followerOf().isEmpty()) {
return result(players, FollowerStatus.NOT_FOLLOWER);
}
return updated(players, withFollower(target, Optional.empty()), FollowerStatus.LEFT);
}
public void clearInvitations() {
invitations.clear();
}
private static FollowerResult updated(
Map<UUID, PlayerState> players,
PlayerState updated,
FollowerStatus status
) {
Map<UUID, PlayerState> replacement = new HashMap<>(players);
replacement.put(updated.playerId(), updated);
return result(replacement, status);
}
private static FollowerResult result(
Map<UUID, PlayerState> players,
FollowerStatus status
) {
return new FollowerResult(players, status);
}
private static PlayerState withFollower(
PlayerState player,
Optional<UUID> followerOf
) {
return new PlayerState(
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
player.tyrantClass(), followerOf, player.cooldownEnds(),
player.readyAbilityItems(), player.capturedMobs()
);
}
}
@@ -0,0 +1,13 @@
package games.dmg.spigottyrant;
public enum FollowerStatus {
INVITED,
JOINED,
DISMISSED,
LEFT,
GAME_NOT_RUNNING,
NOT_VIGILANTE,
INELIGIBLE,
NO_INVITATION,
NOT_FOLLOWER
}
@@ -7,4 +7,6 @@ public interface OnlinePlayerDirectory {
Player findByName(String name); Player findByName(String name);
Player findById(UUID playerId); Player findById(UUID playerId);
java.util.Set<UUID> onlinePlayerIds();
} }
@@ -0,0 +1,4 @@
package games.dmg.spigottyrant;
public record ParticipationResult(PlayerState player, ParticipationStatus status) {
}
@@ -0,0 +1,68 @@
package games.dmg.spigottyrant;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
public final class ParticipationService {
private final Duration optOutDuration;
public ParticipationService(Duration optOutDuration) {
if (optOutDuration.isNegative() || optOutDuration.isZero()) {
throw new IllegalArgumentException("optOutDuration must be positive");
}
this.optOutDuration = optOutDuration;
}
public ParticipationResult optOut(GameState game, PlayerState player, Instant now) {
if (game.tyrantId().filter(player.playerId()::equals).isPresent()
|| game.vigilanteId().filter(player.playerId()::equals).isPresent()) {
return new ParticipationResult(
player, ParticipationStatus.MUST_RELINQUISH_ROLE
);
}
if (player.optedOutUntil().isPresent()) {
return new ParticipationResult(player, ParticipationStatus.ALREADY_OPTED_OUT);
}
PlayerState updated = new PlayerState(
player.playerId(),
player.latestName(),
player.lastLogin(),
Optional.of(now.plus(optOutDuration)),
TyrantClass.NONE,
Optional.empty(),
Map.of(),
Set.of(),
player.capturedMobs()
);
return new ParticipationResult(updated, ParticipationStatus.OPTED_OUT);
}
public ParticipationResult optIn(PlayerState player, Instant now) {
if (player.optedOutUntil().isEmpty()) {
return new ParticipationResult(player, ParticipationStatus.ALREADY_OPTED_IN);
}
if (now.isBefore(player.optedOutUntil().orElseThrow())) {
return new ParticipationResult(player, ParticipationStatus.COOLDOWN_ACTIVE);
}
PlayerState updated = new PlayerState(
player.playerId(),
player.latestName(),
player.lastLogin(),
Optional.empty(),
player.tyrantClass(),
player.followerOf(),
player.cooldownEnds(),
player.readyAbilityItems(),
player.capturedMobs()
);
return new ParticipationResult(updated, ParticipationStatus.OPTED_IN);
}
public ParticipationStatus status(PlayerState player, Instant now) {
return player.optedOutUntil().isPresent()
? ParticipationStatus.OPTED_OUT : ParticipationStatus.OPTED_IN;
}
}
@@ -0,0 +1,10 @@
package games.dmg.spigottyrant;
public enum ParticipationStatus {
OPTED_IN,
OPTED_OUT,
COOLDOWN_ACTIVE,
MUST_RELINQUISH_ROLE,
ALREADY_OPTED_IN,
ALREADY_OPTED_OUT
}
@@ -35,9 +35,10 @@ public final class PlayerJoinListener implements Listener {
current.cooldownEnds(), current.readyAbilityItems(), current.capturedMobs() current.cooldownEnds(), current.readyAbilityItems(), current.capturedMobs()
) )
); );
String reminder = ClassAssignmentMessages.loginReminder(player); for (String message : PlayerStatusMessages.forJoin(
if (!reminder.isEmpty()) { stateManager.game(), player, clock.instant()
event.getPlayer().sendMessage(reminder); )) {
event.getPlayer().sendMessage(message);
} }
if (presentation != null if (presentation != null
&& stateManager.game().lifecycle() == GameLifecycle.RUNNING && stateManager.game().lifecycle() == GameLifecycle.RUNNING
@@ -0,0 +1,53 @@
package games.dmg.spigottyrant;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
public final class PlayerStatusMessages {
private PlayerStatusMessages() {
}
public static List<String> forJoin(
GameState game,
PlayerState player,
Instant now
) {
List<String> messages = new ArrayList<>();
messages.add("Game: " + game.lifecycle());
boolean hasRole = false;
if (game.tyrantId().filter(player.playerId()::equals).isPresent()) {
messages.add("Role: TYRANT. Level " + game.tyrantLevel() + ", choices "
+ game.unspentChoices() + ", abilities " + game.purchases() + ".");
hasRole = true;
}
if (game.vigilanteId().filter(player.playerId()::equals).isPresent()) {
messages.add("Role: VIGILANTE.");
hasRole = true;
} else if (player.followerOf().isPresent()) {
messages.add("Role: FOLLOWER of " + player.followerOf().orElseThrow() + ".");
hasRole = true;
}
if (player.tyrantClass() != TyrantClass.NONE) {
messages.add(ClassAssignmentMessages.loginReminder(player));
hasRole = true;
}
player.cooldownEnds().entrySet().stream()
.sorted(java.util.Map.Entry.comparingByKey())
.forEach(entry -> {
long seconds = Math.max(
0L, Duration.between(now, entry.getValue()).toSeconds()
);
messages.add("Cooldown: " + entry.getKey() + " " + seconds + "s remaining.");
});
if (player.optedOutUntil().isPresent()) {
messages.add("Participation: OPTED OUT. You may use /tyrant optin at "
+ player.optedOutUntil().orElseThrow() + ".");
} else if (!hasRole) {
messages.add("You currently have no role or class. Use /tyrant optout "
+ "if you do not want to participate.");
}
return List.copyOf(messages);
}
}
@@ -28,6 +28,7 @@ public record PluginSettings(
int fixerStrengthLevel, int fixerStrengthLevel,
int fixerNormalHeartRows, int fixerNormalHeartRows,
int fixerNearTyrantHeartRows, int fixerNearTyrantHeartRows,
Duration vigilanteCombatDuration,
Duration rosterIntelligenceCooldown, Duration rosterIntelligenceCooldown,
int tyrantStrengthLevel, int tyrantStrengthLevel,
int tyrantResistanceLevel, int tyrantResistanceLevel,
@@ -65,6 +66,7 @@ public record PluginSettings(
"fixer-near-tyrant-heart-rows must be at least fixer-normal-heart-rows" "fixer-near-tyrant-heart-rows must be at least fixer-normal-heart-rows"
); );
} }
requirePositive(vigilanteCombatDuration, "vigilante-combat-seconds");
requirePositive(rosterIntelligenceCooldown, "roster-intelligence-cooldown-seconds"); requirePositive(rosterIntelligenceCooldown, "roster-intelligence-cooldown-seconds");
requireEffectLevel(tyrantStrengthLevel, "tyrant-strength-level"); requireEffectLevel(tyrantStrengthLevel, "tyrant-strength-level");
requireEffectLevel(tyrantResistanceLevel, "tyrant-resistance-level"); requireEffectLevel(tyrantResistanceLevel, "tyrant-resistance-level");
@@ -99,6 +101,7 @@ public record PluginSettings(
integer(values, "fixer-strength-level", 1), integer(values, "fixer-strength-level", 1),
integer(values, "fixer-normal-heart-rows", 2), integer(values, "fixer-normal-heart-rows", 2),
integer(values, "fixer-near-tyrant-heart-rows", 3), integer(values, "fixer-near-tyrant-heart-rows", 3),
duration(values, "vigilante-combat-seconds", Duration.ofSeconds(15)),
duration(values, "roster-intelligence-cooldown-seconds", Duration.ofHours(24)), duration(values, "roster-intelligence-cooldown-seconds", Duration.ofHours(24)),
integer(values, "tyrant-strength-level", 1), integer(values, "tyrant-strength-level", 1),
integer(values, "tyrant-resistance-level", 1), integer(values, "tyrant-resistance-level", 1),
@@ -0,0 +1,7 @@
package games.dmg.spigottyrant;
public record RelinquishmentResult(
LifecycleState state,
RelinquishmentStatus status
) {
}
@@ -0,0 +1,7 @@
package games.dmg.spigottyrant;
public enum RelinquishmentStatus {
RELINQUISHED,
NO_CENTRAL_ROLE,
GAME_NOT_RUNNING
}
@@ -30,7 +30,7 @@ public final class RoleCandidateSelector {
|| player.lastLogin().orElseThrow().isBefore(cutoff)) { || player.lastLogin().orElseThrow().isBefore(cutoff)) {
continue; continue;
} }
if (player.optedOutUntil().filter(deadline -> deadline.isAfter(now)).isPresent()) { if (player.optedOutUntil().isPresent()) {
continue; continue;
} }
eligible.add(player.playerId()); eligible.add(player.playerId());
@@ -0,0 +1,234 @@
package games.dmg.spigottyrant;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.random.RandomGenerator;
public final class RoleMaintenanceService {
private final RoleCandidateSelector candidates;
private final TyrantSuccessionService succession;
private final Duration inactivity;
private final Duration activityWindow;
private final Duration pendingTimeout;
private final RandomGenerator random;
public RoleMaintenanceService(
RoleCandidateSelector candidates,
TyrantSuccessionService succession,
Duration inactivity,
Duration activityWindow,
Duration pendingTimeout,
RandomGenerator random
) {
this.candidates = candidates;
this.succession = succession;
this.inactivity = inactivity;
this.activityWindow = activityWindow;
this.pendingTimeout = pendingTimeout;
this.random = random;
}
public LifecycleState maintain(
PersistentState state,
Set<UUID> onlinePlayerIds,
Instant now
) {
if (state.game().lifecycle() != GameLifecycle.RUNNING) {
return new LifecycleState(state.game(), state.players());
}
LifecycleState current = confirmPending(state, onlinePlayerIds, now);
if (isInactive(current.game().tyrantId(), current.players(), now)) {
return succession.succeed(
current.asPersistentState(), Optional.empty(), onlinePlayerIds, now
);
}
if (isInactive(current.game().vigilanteId(), current.players(), now)) {
current = rerollVigilante(
current.asPersistentState(), current.game().vigilanteId(), onlinePlayerIds, now
);
}
current = rerollExpiredTyrant(current, onlinePlayerIds, now);
current = rerollExpiredVigilante(current, onlinePlayerIds, now);
if (current.game().tyrantId().isEmpty() && current.game().pendingTyrant().isEmpty()) {
current = selectTyrant(current, Optional.empty(), onlinePlayerIds, now);
}
if (current.game().tyrantId().isPresent()
&& current.game().vigilanteId().isEmpty()
&& current.game().pendingVigilante().isEmpty()) {
current = rerollVigilante(
current.asPersistentState(), Optional.empty(), onlinePlayerIds, now
);
}
return current;
}
public LifecycleState rerollVigilante(
PersistentState state,
Optional<UUID> previousVigilante,
Set<UUID> onlinePlayerIds,
Instant now
) {
Map<UUID, PlayerState> players = clearFollowers(state.players(), previousVigilante);
Set<UUID> excluded = state.game().tyrantId().map(Set::of).orElseGet(Set::of);
Optional<UUID> selected = candidates.select(
players, excluded, previousVigilante, now, activityWindow, random
);
Optional<UUID> active = selected.filter(onlinePlayerIds::contains);
Optional<PendingSelection> pending = selected
.filter(playerId -> active.isEmpty())
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
GameState game = copyRoles(
state.game(), state.game().tyrantId(), active,
state.game().pendingTyrant(), pending
);
return new LifecycleState(game, players);
}
private LifecycleState confirmPending(
PersistentState state,
Set<UUID> onlinePlayerIds,
Instant now
) {
GameState game = state.game();
Optional<UUID> tyrant = game.tyrantId();
Optional<UUID> vigilante = game.vigilanteId();
Optional<PendingSelection> pendingTyrant = game.pendingTyrant();
Optional<PendingSelection> pendingVigilante = game.pendingVigilante();
if (pendingTyrant.isPresent()) {
PendingSelection pending = pendingTyrant.orElseThrow();
if (!now.isAfter(pending.expiresAt())
&& onlinePlayerIds.contains(pending.candidateId())
&& eligibleAtConfirmation(state.players().get(pending.candidateId()), now)
&& vigilante.filter(pending.candidateId()::equals).isEmpty()) {
tyrant = Optional.of(pending.candidateId());
pendingTyrant = Optional.empty();
}
}
if (pendingVigilante.isPresent()) {
PendingSelection pending = pendingVigilante.orElseThrow();
if (!now.isAfter(pending.expiresAt())
&& onlinePlayerIds.contains(pending.candidateId())
&& eligibleAtConfirmation(state.players().get(pending.candidateId()), now)
&& tyrant.filter(pending.candidateId()::equals).isEmpty()) {
vigilante = Optional.of(pending.candidateId());
pendingVigilante = Optional.empty();
}
}
return new LifecycleState(
copyRoles(game, tyrant, vigilante, pendingTyrant, pendingVigilante),
state.players()
);
}
private LifecycleState rerollExpiredTyrant(
LifecycleState state,
Set<UUID> onlinePlayerIds,
Instant now
) {
Optional<PendingSelection> pending = state.game().pendingTyrant();
if (pending.isEmpty() || !now.isAfter(pending.orElseThrow().expiresAt())) {
return state;
}
return selectTyrant(
state,
Optional.of(pending.orElseThrow().candidateId()),
onlinePlayerIds,
now
);
}
private LifecycleState selectTyrant(
LifecycleState state,
Optional<UUID> previous,
Set<UUID> onlinePlayerIds,
Instant now
) {
Set<UUID> excluded = state.game().vigilanteId().map(Set::of).orElseGet(Set::of);
Optional<UUID> selected = candidates.select(
state.players(), excluded, previous, now, activityWindow, random
);
Optional<UUID> active = selected.filter(onlinePlayerIds::contains);
Optional<PendingSelection> pending = selected
.filter(playerId -> active.isEmpty())
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
GameState game = copyRoles(
state.game(), active, state.game().vigilanteId(), pending,
state.game().pendingVigilante()
);
return new LifecycleState(game, state.players());
}
private LifecycleState rerollExpiredVigilante(
LifecycleState state,
Set<UUID> onlinePlayerIds,
Instant now
) {
Optional<PendingSelection> pending = state.game().pendingVigilante();
if (pending.isEmpty() || !now.isAfter(pending.orElseThrow().expiresAt())) {
return state;
}
return rerollVigilante(
state.asPersistentState(),
Optional.of(pending.orElseThrow().candidateId()),
onlinePlayerIds,
now
);
}
private boolean isInactive(
Optional<UUID> roleHolder,
Map<UUID, PlayerState> players,
Instant now
) {
if (roleHolder.isEmpty()) {
return false;
}
PlayerState player = players.get(roleHolder.orElseThrow());
return player != null
&& player.lastLogin().isPresent()
&& !player.lastLogin().orElseThrow().plus(inactivity).isAfter(now);
}
private static boolean eligibleAtConfirmation(PlayerState player, Instant now) {
return player != null && player.optedOutUntil().isEmpty();
}
private static Map<UUID, PlayerState> clearFollowers(
Map<UUID, PlayerState> players,
Optional<UUID> previousVigilante
) {
if (previousVigilante.isEmpty()) {
return players;
}
Map<UUID, PlayerState> updated = new HashMap<>();
UUID previous = previousVigilante.orElseThrow();
for (PlayerState player : players.values()) {
Optional<UUID> followerOf = player.followerOf().filter(id -> !id.equals(previous));
updated.put(player.playerId(), new PlayerState(
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
player.tyrantClass(), followerOf, player.cooldownEnds(),
player.readyAbilityItems(), player.capturedMobs()
));
}
return Map.copyOf(updated);
}
private static GameState copyRoles(
GameState game,
Optional<UUID> tyrant,
Optional<UUID> vigilante,
Optional<PendingSelection> pendingTyrant,
Optional<PendingSelection> pendingVigilante
) {
return new GameState(
game.lifecycle(), tyrant, vigilante, pendingTyrant, pendingVigilante,
game.pausedAt(), game.accumulatedPausedTime(), game.tyrantLevel(),
game.unspentChoices(), game.purchases()
);
}
}
@@ -0,0 +1,44 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.bukkit.Server;
import org.bukkit.entity.Player;
public final class RoleMaintenanceTask implements Runnable {
private final TyrantStateManager stateManager;
private final RoleMaintenanceService maintenance;
private final TyrantPresentation presentation;
private final Server server;
private final Clock clock;
public RoleMaintenanceTask(
TyrantStateManager stateManager,
RoleMaintenanceService maintenance,
TyrantPresentation presentation,
Server server,
Clock clock
) {
this.stateManager = stateManager;
this.maintenance = maintenance;
this.presentation = presentation;
this.server = server;
this.clock = clock;
}
@Override
public void run() {
PersistentState before = stateManager.snapshot();
Set<UUID> online = server.getOnlinePlayers().stream()
.map(Player::getUniqueId)
.collect(Collectors.toUnmodifiableSet());
LifecycleState after = maintenance.maintain(before, online, clock.instant());
if (!after.asPersistentState().equals(before)) {
stateManager.replaceState(after);
stateManager.saveIfDirty();
presentation.reconcile(after.game().tyrantId());
}
}
}
@@ -0,0 +1,51 @@
package games.dmg.spigottyrant;
import java.time.Instant;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
public final class RoleRelinquishmentService {
private final TyrantSuccessionService succession;
private final RoleMaintenanceService maintenance;
public RoleRelinquishmentService(
TyrantSuccessionService succession,
RoleMaintenanceService maintenance
) {
this.succession = succession;
this.maintenance = maintenance;
}
public RelinquishmentResult relinquish(
PersistentState state,
UUID playerId,
Set<UUID> onlinePlayerIds,
Instant now
) {
if (state.game().lifecycle() != GameLifecycle.RUNNING) {
return new RelinquishmentResult(
new LifecycleState(state.game(), state.players()),
RelinquishmentStatus.GAME_NOT_RUNNING
);
}
if (state.game().tyrantId().filter(playerId::equals).isPresent()) {
return new RelinquishmentResult(
succession.succeed(state, Optional.empty(), onlinePlayerIds, now),
RelinquishmentStatus.RELINQUISHED
);
}
if (state.game().vigilanteId().filter(playerId::equals).isPresent()) {
return new RelinquishmentResult(
maintenance.rerollVigilante(
state, Optional.of(playerId), onlinePlayerIds, now
),
RelinquishmentStatus.RELINQUISHED
);
}
return new RelinquishmentResult(
new LifecycleState(state.game(), state.players()),
RelinquishmentStatus.NO_CENTRAL_ROLE
);
}
}
@@ -11,6 +11,9 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
private PluginSettings settings; private PluginSettings settings;
private TyrantStateManager stateManager; private TyrantStateManager stateManager;
private TyrantPresentation tyrantPresentation; private TyrantPresentation tyrantPresentation;
private VigilanteEffectController vigilanteEffects;
private AssassinEffectController assassinEffects;
private FixerEffectController fixerEffects;
@Override @Override
public void onEnable() { public void onEnable() {
@@ -28,50 +31,150 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
return; return;
} }
Clock clock = Clock.systemUTC();
RoleCandidateSelector candidateSelector = new RoleCandidateSelector();
java.util.random.RandomGenerator random = java.util.random.RandomGenerator.getDefault();
TyrantSuccessionService succession = new TyrantSuccessionService(
candidateSelector,
settings.candidateActivityWindow(),
settings.pendingSelectionTimeout(),
random
);
RoleMaintenanceService maintenance = new RoleMaintenanceService(
candidateSelector,
succession,
settings.roleInactivity(),
settings.candidateActivityWindow(),
settings.pendingSelectionTimeout(),
random
);
BukkitOnlinePlayerDirectory onlinePlayers = new BukkitOnlinePlayerDirectory();
FollowerService followers = new FollowerService();
AbilityReadinessService readiness = new AbilityReadinessService();
AbilityItemService abilityItems = new BukkitAbilityItemService(this, settings);
AssassinAbilityService assassinAbilities = new AssassinAbilityService(
readiness, settings
);
assassinEffects = new AssassinEffectController(
stateManager, getServer(), settings, clock
);
FixerAbilityService fixerAbilities = new FixerAbilityService(readiness, settings);
fixerEffects = new FixerEffectController(stateManager, getServer(), settings, clock);
VigilanteCombatTracker combatTracker = new VigilanteCombatTracker();
vigilanteEffects = new VigilanteEffectController(
stateManager, getServer(), combatTracker, settings, clock
);
tyrantPresentation = new BukkitTyrantPresentation(getServer()); tyrantPresentation = new BukkitTyrantPresentation(getServer());
ManagedRoleEffects managedEffects = new BukkitManagedRoleEffects( ManagedRoleEffects managedEffects = new BukkitManagedRoleEffects(
stateManager, tyrantPresentation stateManager, tyrantPresentation
); );
managedEffects.restoreAll(); managedEffects.restoreAll();
Objects.requireNonNull(getCommand("tyrant"), "Missing tyrant command metadata") Objects.requireNonNull(getCommand("tyrant"), "Missing tyrant command metadata")
.setExecutor(new TyrantCommand(stateManager, new TyrantProgressionService())); .setExecutor(new TyrantCommand(
stateManager,
new TyrantProgressionService(),
new ClassAssignmentService(),
onlinePlayers,
clock,
new ParticipationService(settings.optOutDuration()),
new RoleRelinquishmentService(succession, maintenance),
tyrantPresentation,
abilityItems
));
Objects.requireNonNull(getCommand("vigilante"), "Missing vigilante metadata")
.setExecutor(new VigilanteCommand(stateManager, followers, onlinePlayers));
Objects.requireNonNull(getCommand("tyrantadmin"), "Missing tyrantadmin metadata") Objects.requireNonNull(getCommand("tyrantadmin"), "Missing tyrantadmin metadata")
.setExecutor(new TyrantAdminCommand( .setExecutor(new TyrantAdminCommand(
stateManager, stateManager,
new GameLifecycleService(), new GameLifecycleService(),
new BukkitOnlinePlayerDirectory(), onlinePlayers,
managedEffects, managedEffects,
Clock.systemUTC(), clock,
new RoleCandidateSelector(), candidateSelector,
settings.candidateActivityWindow(), settings.candidateActivityWindow(),
settings.pendingSelectionTimeout(), settings.pendingSelectionTimeout(),
java.util.random.RandomGenerator.getDefault() random
)); ));
getServer().getPluginManager().registerEvents( getServer().getPluginManager().registerEvents(
new PlayerJoinListener(stateManager, Clock.systemUTC(), tyrantPresentation), new PlayerJoinListener(stateManager, clock, tyrantPresentation),
this this
); );
getServer().getPluginManager().registerEvents( getServer().getPluginManager().registerEvents(
new TyrantDeathListener( new TyrantDeathListener(
stateManager, stateManager, succession, tyrantPresentation, getServer(), clock
new TyrantSuccessionService(
new RoleCandidateSelector(),
settings.candidateActivityWindow(),
settings.pendingSelectionTimeout(),
java.util.random.RandomGenerator.getDefault()
),
tyrantPresentation,
getServer(),
Clock.systemUTC()
), ),
this this
); );
getServer().getPluginManager().registerEvents(
new VigilanteDeathListener(
stateManager, new TyrantProgressionService(), maintenance,
getServer(), clock
),
this
);
getServer().getPluginManager().registerEvents(
new VigilanteCombatListener(
stateManager, combatTracker, clock, settings.vigilanteCombatDuration()
),
this
);
getServer().getPluginManager().registerEvents(
new BoundItemListener(abilityItems),
this
);
getServer().getPluginManager().registerEvents(
new AssassinItemListener(
stateManager, abilityItems, assassinAbilities, settings, clock
),
this
);
getServer().getPluginManager().registerEvents(
new AssassinJumpListener(stateManager, assassinAbilities, clock),
this
);
getServer().getPluginManager().registerEvents(
new FixerItemListener(
stateManager, abilityItems, fixerAbilities, settings, clock
),
this
);
long maintenanceTicks = Math.max(
1L, Math.multiplyExact(settings.selectionRetryInterval().toSeconds(), 20L)
);
getServer().getScheduler().runTaskTimer(
this,
new RoleMaintenanceTask(
stateManager, maintenance, tyrantPresentation, getServer(), clock
),
maintenanceTicks,
maintenanceTicks
);
getServer().getScheduler().runTaskTimer(this, vigilanteEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, assassinEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, fixerEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(
this,
new AbilityItemRefreshTask(
stateManager, readiness, abilityItems, getServer(), clock
),
20L,
20L
);
getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L); getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L);
getLogger().info("Spigot Tyrant enabled."); getLogger().info("Spigot Tyrant enabled.");
} }
@Override @Override
public void onDisable() { public void onDisable() {
if (fixerEffects != null) {
fixerEffects.clearAll();
}
if (assassinEffects != null) {
assassinEffects.clearAll();
}
if (vigilanteEffects != null) {
vigilanteEffects.clear();
}
if (tyrantPresentation != null) { if (tyrantPresentation != null) {
tyrantPresentation.clear(); tyrantPresentation.clear();
} }
@@ -187,9 +187,7 @@ public final class TyrantAdminCommand implements CommandExecutor {
sender.sendMessage("Pending Tyrant: " + game.pendingTyrant()); sender.sendMessage("Pending Tyrant: " + game.pendingTyrant());
sender.sendMessage("Pending Vigilante: " + game.pendingVigilante()); sender.sendMessage("Pending Vigilante: " + game.pendingVigilante());
for (PlayerState player : stateManager.players().values()) { for (PlayerState player : stateManager.players().values()) {
boolean optedOut = player.optedOutUntil() boolean optedOut = player.optedOutUntil().isPresent();
.filter(deadline -> deadline.isAfter(clock.instant()))
.isPresent();
sender.sendMessage( sender.sendMessage(
player.latestName() + " (" + player.playerId() + "): class=" player.latestName() + " (" + player.playerId() + "): class="
+ player.tyrantClass() + ", followerOf=" + player.tyrantClass() + ", followerOf="
@@ -1,6 +1,7 @@
package games.dmg.spigottyrant; package games.dmg.spigottyrant;
import java.time.Clock; import java.time.Clock;
import java.time.Duration;
import java.util.Locale; import java.util.Locale;
import org.bukkit.ChatColor; import org.bukkit.ChatColor;
import org.bukkit.command.Command; import org.bukkit.command.Command;
@@ -14,6 +15,10 @@ public final class TyrantCommand implements CommandExecutor {
private final ClassAssignmentService assignments; private final ClassAssignmentService assignments;
private final OnlinePlayerDirectory onlinePlayers; private final OnlinePlayerDirectory onlinePlayers;
private final Clock clock; private final Clock clock;
private final ParticipationService participation;
private final RoleRelinquishmentService relinquishment;
private final TyrantPresentation presentation;
private final AbilityItemService abilityItems;
public TyrantCommand( public TyrantCommand(
TyrantStateManager stateManager, TyrantStateManager stateManager,
@@ -34,12 +39,33 @@ public final class TyrantCommand implements CommandExecutor {
ClassAssignmentService assignments, ClassAssignmentService assignments,
OnlinePlayerDirectory onlinePlayers, OnlinePlayerDirectory onlinePlayers,
Clock clock Clock clock
) {
this(
stateManager, progression, assignments, onlinePlayers, clock,
new ParticipationService(Duration.ofDays(7)), null, null, null
);
}
public TyrantCommand(
TyrantStateManager stateManager,
TyrantProgressionService progression,
ClassAssignmentService assignments,
OnlinePlayerDirectory onlinePlayers,
Clock clock,
ParticipationService participation,
RoleRelinquishmentService relinquishment,
TyrantPresentation presentation,
AbilityItemService abilityItems
) { ) {
this.stateManager = stateManager; this.stateManager = stateManager;
this.progression = progression; this.progression = progression;
this.assignments = assignments; this.assignments = assignments;
this.onlinePlayers = onlinePlayers; this.onlinePlayers = onlinePlayers;
this.clock = clock; this.clock = clock;
this.participation = participation;
this.relinquishment = relinquishment;
this.presentation = presentation;
this.abilityItems = abilityItems;
} }
@Override @Override
@@ -65,10 +91,30 @@ public final class TyrantCommand implements CommandExecutor {
assign(player, arguments[1], arguments[2]); assign(player, arguments[1], arguments[2]);
return true; return true;
} }
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("status")) {
showPlayerStatus(player);
return true;
}
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("optout")) {
changeParticipation(player, false);
return true;
}
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("optin")) {
changeParticipation(player, true);
return true;
}
if (arguments.length >= 1 && arguments[0].equalsIgnoreCase("relinquish")) {
relinquish(player, arguments);
return true;
}
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("item")) {
recoverItems(player);
return true;
}
player.sendMessage( player.sendMessage(
ChatColor.YELLOW ChatColor.YELLOW
+ "Usage: /tyrant choices | /tyrant buy <unlock> | " + "Usage: /tyrant <status|choices|buy|assign|item|optout|optin|"
+ "/tyrant assign <class> <player>" + "relinquish confirm>"
); );
return true; return true;
} }
@@ -146,16 +192,91 @@ public final class TyrantCommand implements CommandExecutor {
} }
stateManager.replacePlayers(result.players()); stateManager.replacePlayers(result.players());
stateManager.saveIfDirty(); stateManager.saveIfDirty();
result.previousHolderId().map(onlinePlayers::findById).ifPresent(previous -> result.previousHolderId().map(onlinePlayers::findById).ifPresent(previous -> {
if (abilityItems != null) {
abilityItems.removeAll(previous);
}
previous.sendMessage(ChatColor.YELLOW + "You are no longer the " previous.sendMessage(ChatColor.YELLOW + "You are no longer the "
+ tyrantClass.name() + ".") + tyrantClass.name() + ".");
); });
if (abilityItems != null) {
abilityItems.removeInvalid(target, result.players().get(target.getUniqueId()));
abilityItems.recover(target, result.players().get(target.getUniqueId()));
}
target.sendMessage(ChatColor.GREEN + "You are now the " + tyrantClass.name() + "."); target.sendMessage(ChatColor.GREEN + "You are now the " + tyrantClass.name() + ".");
tyrant.sendMessage( tyrant.sendMessage(
ChatColor.GREEN + "Assigned " + tyrantClass.name() + " to " + target.getName() + "." ChatColor.GREEN + "Assigned " + tyrantClass.name() + " to " + target.getName() + "."
); );
} }
private void showPlayerStatus(Player player) {
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
PlayerStatusMessages.forJoin(stateManager.game(), state, clock.instant())
.forEach(player::sendMessage);
}
private void changeParticipation(Player player, boolean optIn) {
ParticipationResult[] result = new ParticipationResult[1];
stateManager.updatePlayer(player.getUniqueId(), player.getName(), current -> {
result[0] = optIn
? participation.optIn(current, clock.instant())
: participation.optOut(stateManager.game(), current, clock.instant());
return result[0].player();
});
if (result[0].status() == ParticipationStatus.OPTED_IN
|| result[0].status() == ParticipationStatus.OPTED_OUT) {
stateManager.saveIfDirty();
}
if (result[0].status() == ParticipationStatus.MUST_RELINQUISH_ROLE) {
player.sendMessage(ChatColor.RED
+ "Use /tyrant relinquish confirm before opting out.");
return;
}
if (result[0].status() == ParticipationStatus.COOLDOWN_ACTIVE) {
player.sendMessage(ChatColor.RED + "You may opt in at "
+ result[0].player().optedOutUntil().orElseThrow() + ".");
return;
}
player.sendMessage(ChatColor.YELLOW + "Participation: "
+ readable(result[0].status()) + ".");
}
private void recoverItems(Player player) {
if (abilityItems == null) {
player.sendMessage(ChatColor.RED + "Ability item recovery is unavailable.");
return;
}
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
abilityItems.recover(player, state);
player.sendMessage(ChatColor.YELLOW
+ "Recovered every currently ready missing class item that could fit.");
}
private void relinquish(Player player, String[] arguments) {
if (arguments.length != 2 || !arguments[1].equalsIgnoreCase("confirm")) {
player.sendMessage(ChatColor.RED
+ "Relinquishing rerolls your role. Use /tyrant relinquish confirm.");
return;
}
if (relinquishment == null) {
player.sendMessage(ChatColor.RED + "Role relinquishment is unavailable.");
return;
}
RelinquishmentResult result = relinquishment.relinquish(
stateManager.snapshot(), player.getUniqueId(), onlinePlayers.onlinePlayerIds(),
clock.instant()
);
if (result.status() == RelinquishmentStatus.RELINQUISHED) {
stateManager.replaceState(result.state());
stateManager.saveIfDirty();
if (presentation != null) {
presentation.reconcile(result.state().game().tyrantId());
}
}
player.sendMessage(ChatColor.YELLOW + "Relinquishment: "
+ readable(result.status()) + ".");
}
private static String readable(Enum<?> status) { private static String readable(Enum<?> status) {
return status.name().toLowerCase(Locale.ROOT).replace('_', ' '); return status.name().toLowerCase(Locale.ROOT).replace('_', ' ');
} }
@@ -0,0 +1,50 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.time.Duration;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
public final class VigilanteCombatListener implements Listener {
private final TyrantStateManager stateManager;
private final VigilanteCombatTracker tracker;
private final Clock clock;
private final Duration combatDuration;
public VigilanteCombatListener(
TyrantStateManager stateManager,
VigilanteCombatTracker tracker,
Clock clock,
Duration combatDuration
) {
this.stateManager = stateManager;
this.tracker = tracker;
this.clock = clock;
this.combatDuration = combatDuration;
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onDamage(EntityDamageByEntityEvent event) {
if (!(event.getEntity() instanceof Player damaged)
|| !(event.getDamager() instanceof Player attacker)) {
return;
}
GameState game = stateManager.game();
if (game.lifecycle() != GameLifecycle.RUNNING
|| game.tyrantId().isEmpty() || game.vigilanteId().isEmpty()) {
return;
}
UUID first = damaged.getUniqueId();
UUID second = attacker.getUniqueId();
UUID tyrant = game.tyrantId().orElseThrow();
UUID vigilante = game.vigilanteId().orElseThrow();
if ((first.equals(tyrant) && second.equals(vigilante))
|| (first.equals(vigilante) && second.equals(tyrant))) {
tracker.markCombat(clock.instant(), combatDuration);
}
}
}
@@ -0,0 +1,21 @@
package games.dmg.spigottyrant;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
public final class VigilanteCombatTracker {
private Optional<Instant> activeUntil = Optional.empty();
public void markCombat(Instant now, Duration duration) {
activeUntil = Optional.of(now.plus(duration));
}
public boolean isActive(Instant now) {
return activeUntil.filter(deadline -> deadline.isAfter(now)).isPresent();
}
public void clear() {
activeUntil = Optional.empty();
}
}
@@ -0,0 +1,83 @@
package games.dmg.spigottyrant;
import java.util.Locale;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
public final class VigilanteCommand implements CommandExecutor {
private final TyrantStateManager stateManager;
private final FollowerService followers;
private final OnlinePlayerDirectory onlinePlayers;
public VigilanteCommand(
TyrantStateManager stateManager,
FollowerService followers,
OnlinePlayerDirectory onlinePlayers
) {
this.stateManager = stateManager;
this.followers = followers;
this.onlinePlayers = onlinePlayers;
}
@Override
public boolean onCommand(
CommandSender sender,
Command command,
String label,
String[] arguments
) {
if (!(sender instanceof Player player)) {
sender.sendMessage("This command must be used by a player.");
return true;
}
FollowerResult result;
Player target = null;
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("accept")) {
result = followers.accept(stateManager.game(), stateManager.players(),
player.getUniqueId());
} else if (arguments.length == 1 && arguments[0].equalsIgnoreCase("leave")) {
result = followers.leave(stateManager.game(), stateManager.players(),
player.getUniqueId());
} else if (arguments.length == 2
&& (arguments[0].equalsIgnoreCase("invite")
|| arguments[0].equalsIgnoreCase("dismiss"))) {
target = onlinePlayers.findByName(arguments[1]);
if (target == null) {
player.sendMessage(ChatColor.RED + "That player must be online.");
return true;
}
stateManager.player(target.getUniqueId(), target.getName());
result = arguments[0].equalsIgnoreCase("invite")
? followers.invite(
stateManager.game(), stateManager.players(), player.getUniqueId(),
target.getUniqueId()
)
: followers.dismiss(
stateManager.game(), stateManager.players(), player.getUniqueId(),
target.getUniqueId()
);
} else {
player.sendMessage(ChatColor.YELLOW
+ "Usage: /vigilante <invite <player>|accept|dismiss <player>|leave>");
return true;
}
if (result.status() == FollowerStatus.JOINED
|| result.status() == FollowerStatus.DISMISSED
|| result.status() == FollowerStatus.LEFT) {
stateManager.replacePlayers(result.players());
stateManager.saveIfDirty();
}
player.sendMessage(ChatColor.YELLOW + "Follower action: "
+ result.status().name().toLowerCase(Locale.ROOT).replace('_', ' ') + ".");
if (target != null && result.status() == FollowerStatus.INVITED) {
target.sendMessage(ChatColor.YELLOW + player.getName()
+ " invited you to become a Follower. Use /vigilante accept.");
} else if (target != null && result.status() == FollowerStatus.DISMISSED) {
target.sendMessage(ChatColor.YELLOW + "You are no longer a Follower.");
}
return true;
}
}
@@ -0,0 +1,62 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.PlayerDeathEvent;
public final class VigilanteDeathListener implements Listener {
private final TyrantStateManager stateManager;
private final TyrantProgressionService progression;
private final RoleMaintenanceService maintenance;
private final Server server;
private final Clock clock;
public VigilanteDeathListener(
TyrantStateManager stateManager,
TyrantProgressionService progression,
RoleMaintenanceService maintenance,
Server server,
Clock clock
) {
this.stateManager = stateManager;
this.progression = progression;
this.maintenance = maintenance;
this.server = server;
this.clock = clock;
}
@EventHandler(priority = EventPriority.MONITOR)
public void onDeath(PlayerDeathEvent event) {
UUID victimId = event.getEntity().getUniqueId();
GameState game = stateManager.game();
if (game.lifecycle() != GameLifecycle.RUNNING
|| game.vigilanteId().filter(victimId::equals).isEmpty()) {
return;
}
Optional<UUID> killerId = Optional.ofNullable(event.getEntity().getKiller())
.map(Player::getUniqueId);
GameState progressed = progression.recordVigilanteDeath(game, killerId);
PersistentState withProgress = new PersistentState(
progressed, stateManager.players()
);
Set<UUID> online = server.getOnlinePlayers().stream()
.map(Player::getUniqueId)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
LifecycleState rerolled = maintenance.rerollVigilante(
withProgress, Optional.of(victimId), online, clock.instant()
);
stateManager.replaceState(rerolled);
stateManager.saveIfDirty();
String message = ChatColor.YELLOW + "The Vigilante died; a replacement "
+ (rerolled.game().vigilanteId().isPresent() ? "has been selected." : "is pending.");
server.getOnlinePlayers().forEach(player -> player.sendMessage(message));
}
}
@@ -0,0 +1,98 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
public final class VigilanteEffectController implements Runnable {
private static final int EFFECT_TICKS = 30;
private final TyrantStateManager stateManager;
private final Server server;
private final VigilanteCombatTracker tracker;
private final PluginSettings settings;
private final Clock clock;
private Optional<UUID> previousVigilante = Optional.empty();
public VigilanteEffectController(
TyrantStateManager stateManager,
Server server,
VigilanteCombatTracker tracker,
PluginSettings settings,
Clock clock
) {
this.stateManager = stateManager;
this.server = server;
this.tracker = tracker;
this.settings = settings;
this.clock = clock;
}
@Override
public void run() {
GameState game = stateManager.game();
if (!previousVigilante.equals(game.vigilanteId())) {
previousVigilante.map(server::getPlayer).ifPresent(this::removeManagedEffects);
previousVigilante = game.vigilanteId();
}
Player vigilante = game.vigilanteId().map(server::getPlayer).orElse(null);
if (vigilante == null || game.lifecycle() != GameLifecycle.RUNNING
|| !tracker.isActive(clock.instant()) || vigilante.isDead()) {
if (vigilante != null) {
removeManagedEffects(vigilante);
}
return;
}
int followers = nearbyLivingFollowers(game.vigilanteId().orElseThrow(), vigilante);
VigilantePower.Levels levels = VigilantePower.forFollowers(
followers, settings.followerStrengthCap(), settings.followerResistanceCap()
);
apply(vigilante, PotionEffectType.STRENGTH, levels.strength());
apply(vigilante, PotionEffectType.RESISTANCE, levels.resistance());
}
public void clear() {
previousVigilante.map(server::getPlayer).ifPresent(this::removeManagedEffects);
previousVigilante = Optional.empty();
tracker.clear();
}
private int nearbyLivingFollowers(UUID vigilanteId, Player vigilante) {
int count = 0;
double rangeSquared = settings.followerRangeBlocks()
* settings.followerRangeBlocks();
for (PlayerState state : stateManager.players().values()) {
if (state.followerOf().filter(vigilanteId::equals).isEmpty()
|| state.optedOutUntil().isPresent()) {
continue;
}
Player follower = server.getPlayer(state.playerId());
if (follower == null || follower.isDead()
|| !follower.getWorld().equals(vigilante.getWorld())) {
continue;
}
if (follower.getLocation().distanceSquared(vigilante.getLocation()) <= rangeSquared) {
count++;
}
}
return count;
}
private static void apply(Player player, PotionEffectType type, int level) {
if (level == 0) {
player.removePotionEffect(type);
return;
}
player.addPotionEffect(new PotionEffect(
type, EFFECT_TICKS, level - 1, false, true, true
));
}
private void removeManagedEffects(Player player) {
player.removePotionEffect(PotionEffectType.STRENGTH);
player.removePotionEffect(PotionEffectType.RESISTANCE);
}
}
@@ -0,0 +1,23 @@
package games.dmg.spigottyrant;
public final class VigilantePower {
private VigilantePower() {
}
public static Levels forFollowers(
int followerCount,
int strengthCap,
int resistanceCap
) {
if (followerCount < 0 || strengthCap < 1 || resistanceCap < 1) {
throw new IllegalArgumentException("follower count and caps are invalid");
}
return new Levels(
Math.min(followerCount, strengthCap),
Math.min(followerCount, resistanceCap)
);
}
public record Levels(int strength, int resistance) {
}
}
+1
View File
@@ -40,6 +40,7 @@ denied-mob-types:
- WITHER - WITHER
# Tyrant and Vigilante effects # Tyrant and Vigilante effects
vigilante-combat-seconds: 15
roster-intelligence-cooldown-seconds: 86400 roster-intelligence-cooldown-seconds: 86400
tyrant-strength-level: 1 tyrant-strength-level: 1
tyrant-resistance-level: 1 tyrant-resistance-level: 1
+4 -1
View File
@@ -7,7 +7,10 @@ author: dmg.games
commands: commands:
tyrant: tyrant:
description: View and use Spigot Tyrant game features. description: View and use Spigot Tyrant game features.
usage: /tyrant [choices|buy <unlock>|assign <class> <player>] usage: /tyrant <status|choices|buy|assign|optout|optin|relinquish confirm>
vigilante:
description: Manage Vigilante Followers.
usage: /vigilante <invite <player>|accept|dismiss <player>|leave>
tyrantadmin: tyrantadmin:
description: Administer the Spigot Tyrant game. description: Administer the Spigot Tyrant game.
usage: /tyrantadmin <status|start|pause|resume|reset confirm> usage: /tyrantadmin <status|start|pause|resume|reset confirm>
@@ -0,0 +1,60 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class AbilityReadinessServiceTest {
private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
private final AbilityReadinessService service = new AbilityReadinessService();
@Test
void successfulCooldownActivationConsumesItemAndRefreshRestoresIt() {
PlayerState ready = player(
TyrantClass.ASSASSIN, Map.of(), Set.of(Ability.ASSASSIN_INVISIBILITY)
);
PlayerState used = service.consume(
ready, Ability.ASSASSIN_INVISIBILITY, NOW, Duration.ofHours(1)
);
PlayerState refreshed = service.refresh(used, NOW.plus(Duration.ofHours(1)));
assertEquals(Set.of(), used.readyAbilityItems());
assertEquals(NOW.plus(Duration.ofHours(1)),
used.cooldownEnds().get(Ability.ASSASSIN_INVISIBILITY));
assertEquals(Set.of(Ability.ASSASSIN_INVISIBILITY),
refreshed.readyAbilityItems());
assertEquals(Map.of(), refreshed.cooldownEnds());
}
@Test
void tamerItemRemainsReadyWithoutCooldown() {
PlayerState tamer = player(
TyrantClass.TAMER, Map.of(), Set.of(Ability.TAMER_CAPTURE)
);
PlayerState after = service.consume(
tamer, Ability.TAMER_CAPTURE, NOW, Duration.ZERO
);
assertEquals(tamer, after);
}
private static PlayerState player(
TyrantClass tyrantClass,
Map<Ability, Instant> cooldowns,
Set<Ability> ready
) {
return new PlayerState(
UUID.fromString("11111111-1111-1111-1111-111111111111"), "Player",
Optional.empty(), Optional.empty(), tyrantClass, Optional.empty(),
cooldowns, ready, java.util.List.of()
);
}
}
@@ -0,0 +1,60 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class AssassinAbilityServiceTest {
private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
private final PluginSettings settings = PluginSettings.from(Map.of());
private final AssassinAbilityService service = new AssassinAbilityService(
new AbilityReadinessService(), settings
);
@Test
void invisibilityConsumesItemAndDoublesDurationNearTyrant() {
PlayerState ready = assassin(Set.of(Ability.ASSASSIN_INVISIBILITY), Map.of());
AbilityUseResult normal = service.activateInvisibility(ready, NOW, false);
AbilityUseResult near = service.activateInvisibility(ready, NOW, true);
assertEquals(AbilityUseStatus.ACTIVATED, normal.status());
assertEquals(NOW.plusSeconds(600), normal.player().cooldownEnds()
.get(Ability.ASSASSIN_INVISIBILITY_ACTIVE));
assertEquals(NOW.plusSeconds(1200), near.player().cooldownEnds()
.get(Ability.ASSASSIN_INVISIBILITY_ACTIVE));
assertEquals(NOW.plusSeconds(3600), normal.player().cooldownEnds()
.get(Ability.ASSASSIN_INVISIBILITY));
assertEquals(Set.of(), normal.player().readyAbilityItems());
}
@Test
void doubleJumpAddsSpeedWeaknessAndSixtySecondCooldown() {
PlayerState assassin = assassin(Set.of(), Map.of());
AbilityUseResult result = service.doubleJump(assassin, NOW);
AbilityUseResult blocked = service.doubleJump(result.player(), NOW.plusSeconds(30));
assertEquals(AbilityUseStatus.ACTIVATED, result.status());
assertEquals(NOW.plusSeconds(60), result.player().cooldownEnds()
.get(Ability.ASSASSIN_DOUBLE_JUMP));
assertEquals(NOW.plusSeconds(15), result.player().cooldownEnds()
.get(Ability.ASSASSIN_SPEED_ACTIVE));
assertEquals(NOW.plusSeconds(20), result.player().cooldownEnds()
.get(Ability.ASSASSIN_WEAKNESS_ACTIVE));
assertEquals(AbilityUseStatus.COOLDOWN, blocked.status());
}
private static PlayerState assassin(Set<Ability> ready, Map<Ability, Instant> cooldowns) {
return new PlayerState(
UUID.fromString("11111111-1111-1111-1111-111111111111"), "Assassin",
Optional.empty(), Optional.empty(), TyrantClass.ASSASSIN, Optional.empty(),
cooldowns, ready, java.util.List.of()
);
}
}
@@ -46,6 +46,8 @@ final class ClassAssignmentServiceTest {
assertEquals(ClassAssignmentStatus.ASSIGNED, vigilanteResult.status()); assertEquals(ClassAssignmentStatus.ASSIGNED, vigilanteResult.status());
assertEquals(TyrantClass.ASSASSIN, assertEquals(TyrantClass.ASSASSIN,
vigilanteResult.players().get(VIGILANTE).tyrantClass()); vigilanteResult.players().get(VIGILANTE).tyrantClass());
assertEquals(Set.of(Ability.ASSASSIN_INVISIBILITY),
vigilanteResult.players().get(VIGILANTE).readyAbilityItems());
assertEquals(ClassAssignmentStatus.ASSIGNED, followerResult.status()); assertEquals(ClassAssignmentStatus.ASSIGNED, followerResult.status());
assertEquals(TyrantClass.NONE, assertEquals(TyrantClass.NONE,
followerResult.players().get(VIGILANTE).tyrantClass()); followerResult.players().get(VIGILANTE).tyrantClass());
@@ -0,0 +1,37 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class FixerAbilityServiceTest {
private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
private final FixerAbilityService service = new FixerAbilityService(
new AbilityReadinessService(), PluginSettings.from(Map.of())
);
@Test
void boostConsumesItemAndRecordsNormalOrNearTyrantHeartTier() {
PlayerState ready = new PlayerState(
UUID.fromString("11111111-1111-1111-1111-111111111111"), "Fixer",
Optional.empty(), Optional.empty(), TyrantClass.FIXER, Optional.empty(),
Map.of(), Set.of(Ability.FIXER_BOOST), java.util.List.of()
);
FixerUseResult normal = service.activate(ready, NOW, false);
FixerUseResult near = service.activate(ready, NOW, true);
assertEquals(AbilityUseStatus.ACTIVATED, normal.status());
assertEquals(2, normal.heartRows());
assertEquals(3, near.heartRows());
assertEquals(NOW.plusSeconds(600), normal.player().cooldownEnds()
.get(Ability.FIXER_BOOST_ACTIVE));
assertEquals(NOW.plusSeconds(3600), normal.player().cooldownEnds()
.get(Ability.FIXER_BOOST));
}
}
@@ -0,0 +1,70 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class FollowerServiceTest {
private static final UUID TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111");
private static final UUID VIGILANTE = UUID.fromString("22222222-2222-2222-2222-222222222222");
private static final UUID FOLLOWER = UUID.fromString("33333333-3333-3333-3333-333333333333");
private final FollowerService service = new FollowerService();
@Test
void invitationRequiresAcceptanceAndPreservesTyrantClass() {
GameState game = game();
PlayerState target = new PlayerState(
FOLLOWER, "Follower", Optional.empty(), Optional.empty(), TyrantClass.FIXER,
Optional.empty(), Map.of(), Set.of(), java.util.List.of()
);
Map<UUID, PlayerState> players = Map.of(FOLLOWER, target);
FollowerResult invited = service.invite(game, players, VIGILANTE, FOLLOWER);
FollowerResult accepted = service.accept(game, invited.players(), FOLLOWER);
assertEquals(FollowerStatus.INVITED, invited.status());
assertEquals(Optional.empty(), invited.players().get(FOLLOWER).followerOf());
assertEquals(FollowerStatus.JOINED, accepted.status());
assertEquals(Optional.of(VIGILANTE), accepted.players().get(FOLLOWER).followerOf());
assertEquals(TyrantClass.FIXER, accepted.players().get(FOLLOWER).tyrantClass());
}
@Test
void vigilanteCanDismissAndFollowerCanLeave() {
GameState game = game();
PlayerState follower = withFollower(PlayerState.newPlayer(FOLLOWER, "Follower"));
FollowerResult dismissed = service.dismiss(
game, Map.of(FOLLOWER, follower), VIGILANTE, FOLLOWER
);
FollowerResult left = service.leave(
game, Map.of(FOLLOWER, follower), FOLLOWER
);
assertEquals(FollowerStatus.DISMISSED, dismissed.status());
assertEquals(Optional.empty(), dismissed.players().get(FOLLOWER).followerOf());
assertEquals(FollowerStatus.LEFT, left.status());
assertEquals(Optional.empty(), left.players().get(FOLLOWER).followerOf());
}
private static GameState game() {
return new GameState(
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.of(VIGILANTE),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
0, 0, Set.of()
);
}
private static PlayerState withFollower(PlayerState player) {
return new PlayerState(
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
player.tyrantClass(), Optional.of(VIGILANTE), player.cooldownEnds(),
player.readyAbilityItems(), player.capturedMobs()
);
}
}
@@ -0,0 +1,76 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class ParticipationServiceTest {
private static final UUID PLAYER = UUID.fromString("11111111-1111-1111-1111-111111111111");
private static final UUID TYRANT = UUID.fromString("22222222-2222-2222-2222-222222222222");
private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
private final ParticipationService service = new ParticipationService(Duration.ofDays(7));
@Test
void optingOutClearsOrdinaryClassAndFollowerButKeepsPlayerOutAfterCooldown() {
PlayerState assigned = new PlayerState(
PLAYER, "Player", Optional.of(NOW), Optional.empty(), TyrantClass.ASSASSIN,
Optional.of(UUID.randomUUID()),
Map.of(Ability.ASSASSIN_INVISIBILITY, NOW.plusSeconds(30)),
Set.of(Ability.ASSASSIN_INVISIBILITY), java.util.List.of()
);
GameState game = runningGame();
ParticipationResult result = service.optOut(game, assigned, NOW);
assertEquals(ParticipationStatus.OPTED_OUT, result.status());
assertEquals(Optional.of(NOW.plus(Duration.ofDays(7))),
result.player().optedOutUntil());
assertEquals(TyrantClass.NONE, result.player().tyrantClass());
assertEquals(Optional.empty(), result.player().followerOf());
assertEquals(Map.of(), result.player().cooldownEnds());
assertEquals(ParticipationStatus.OPTED_OUT,
service.status(result.player(), NOW.plus(Duration.ofDays(8))));
}
@Test
void optingBackInIsBlockedUntilSevenDaysAndThenClearsOptOut() {
PlayerState player = service.optOut(
runningGame(), PlayerState.newPlayer(PLAYER, "Player"), NOW
).player();
ParticipationResult early = service.optIn(player, NOW.plus(Duration.ofDays(6)));
ParticipationResult ready = service.optIn(player, NOW.plus(Duration.ofDays(7)));
assertEquals(ParticipationStatus.COOLDOWN_ACTIVE, early.status());
assertEquals(ParticipationStatus.OPTED_IN, ready.status());
assertEquals(Optional.empty(), ready.player().optedOutUntil());
}
@Test
void currentCentralRoleMustRelinquishBeforeOptingOut() {
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(PLAYER), Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(), Duration.ZERO, 0, 1, Set.of()
);
ParticipationResult result = service.optOut(
game, PlayerState.newPlayer(PLAYER, "Tyrant"), NOW
);
assertEquals(ParticipationStatus.MUST_RELINQUISH_ROLE, result.status());
assertEquals(Optional.empty(), result.player().optedOutUntil());
}
private static GameState runningGame() {
return new GameState(
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(), Duration.ZERO, 0, 1, Set.of()
);
}
}
@@ -0,0 +1,47 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class PlayerStatusMessagesTest {
private static final UUID PLAYER = UUID.fromString("11111111-1111-1111-1111-111111111111");
private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
@Test
void assignedPlayerSeesGameRoleClassAbilitiesAndCooldowns() {
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.empty(), Optional.of(PLAYER), Optional.empty(),
Optional.empty(), Optional.empty(), Duration.ZERO, 0, 0, Set.of()
);
PlayerState player = new PlayerState(
PLAYER, "Player", Optional.of(NOW), Optional.empty(), TyrantClass.ASSASSIN,
Optional.empty(), Map.of(Ability.ASSASSIN_INVISIBILITY, NOW.plusSeconds(60)),
Set.of(), List.of()
);
List<String> messages = PlayerStatusMessages.forJoin(game, player, NOW);
assertTrue(messages.stream().anyMatch(message -> message.contains("Game: RUNNING")));
assertTrue(messages.stream().anyMatch(message -> message.contains("Role: VIGILANTE")));
assertTrue(messages.stream().anyMatch(message -> message.contains("Class: ASSASSIN")));
assertTrue(messages.stream().anyMatch(message -> message.contains("ASSASSIN_INVISIBILITY")
&& message.contains("60s")));
}
@Test
void unassignedPlayerIsToldHowToOptOut() {
List<String> messages = PlayerStatusMessages.forJoin(
GameState.empty(), PlayerState.newPlayer(PLAYER, "Player"), NOW
);
assertTrue(messages.stream().anyMatch(message -> message.contains("/tyrant optout")));
}
}
@@ -24,6 +24,7 @@ final class PluginMetadataTest {
assertNotNull(commands); assertNotNull(commands);
assertNotNull(commands.get("tyrant")); assertNotNull(commands.get("tyrant"));
assertNotNull(commands.get("tyrantadmin")); assertNotNull(commands.get("tyrantadmin"));
assertNotNull(commands.get("vigilante"));
Map<?, ?> permissions = (Map<?, ?>) plugin.get("permissions"); Map<?, ?> permissions = (Map<?, ?>) plugin.get("permissions");
assertNotNull(permissions); assertNotNull(permissions);
Map<?, ?> admin = (Map<?, ?>) permissions.get("spigottyrant.admin"); Map<?, ?> admin = (Map<?, ?>) permissions.get("spigottyrant.admin");
@@ -0,0 +1,155 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.random.RandomGenerator;
import org.junit.jupiter.api.Test;
final class RoleMaintenanceServiceTest {
private static final UUID TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111");
private static final UUID VIGILANTE = UUID.fromString("22222222-2222-2222-2222-222222222222");
private static final UUID CANDIDATE = UUID.fromString("33333333-3333-3333-3333-333333333333");
private static final UUID OTHER = UUID.fromString("44444444-4444-4444-4444-444444444444");
private static final UUID FOLLOWER = UUID.fromString("55555555-5555-5555-5555-555555555555");
private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
private final RandomGenerator random = RandomGenerator.of("L64X128MixRandom");
private final RoleCandidateSelector selector = new RoleCandidateSelector();
private final RoleMaintenanceService service = new RoleMaintenanceService(
selector,
new TyrantSuccessionService(
selector, Duration.ofHours(24), Duration.ofHours(24), random
),
Duration.ofHours(48),
Duration.ofHours(24),
Duration.ofHours(24),
random
);
@Test
void onlinePendingVigilanteConfirmsWithoutConflictingWithTyrant() {
GameState game = game(
Optional.of(TYRANT), Optional.empty(), Optional.empty(),
Optional.of(new PendingSelection(CANDIDATE, NOW.plusSeconds(60)))
);
PersistentState state = new PersistentState(game, players(
active(TYRANT), active(CANDIDATE)
));
LifecycleState maintained = service.maintain(state, Set.of(TYRANT, CANDIDATE), NOW);
assertEquals(Optional.of(CANDIDATE), maintained.game().vigilanteId());
assertEquals(Optional.empty(), maintained.game().pendingVigilante());
}
@Test
void expiredPendingSelectionRerollsAwayFromPreviousCandidate() {
GameState game = game(
Optional.of(TYRANT), Optional.empty(), Optional.empty(),
Optional.of(new PendingSelection(CANDIDATE, NOW.minusSeconds(1)))
);
PersistentState state = new PersistentState(game, players(
active(TYRANT), active(CANDIDATE), active(OTHER)
));
LifecycleState maintained = service.maintain(state, Set.of(TYRANT), NOW);
assertEquals(OTHER,
maintained.game().pendingVigilante().orElseThrow().candidateId());
}
@Test
void inactiveVigilanteIsRerolledAndFollowersAreCleared() {
PlayerState follower = withFollower(
withLogin(active(FOLLOWER), NOW.minus(Duration.ofHours(25))),
VIGILANTE
);
PlayerState staleVigilante = withLogin(active(VIGILANTE), NOW.minus(Duration.ofHours(49)));
GameState game = game(
Optional.of(TYRANT), Optional.of(VIGILANTE), Optional.empty(), Optional.empty()
);
PersistentState state = new PersistentState(game, Map.of(
TYRANT, active(TYRANT), VIGILANTE, staleVigilante,
FOLLOWER, follower, OTHER, active(OTHER)
));
LifecycleState maintained = service.maintain(state, Set.of(TYRANT, OTHER), NOW);
assertEquals(Optional.of(OTHER), maintained.game().vigilanteId());
assertEquals(Optional.empty(), maintained.players().get(FOLLOWER).followerOf());
assertEquals(Optional.of(TYRANT), maintained.game().tyrantId());
}
@Test
void inactiveTyrantEndsReignAndUsesNormalSuccession() {
PlayerState staleTyrant = withLogin(
active(TYRANT), NOW.minus(Duration.ofHours(48))
);
PlayerState successor = new PlayerState(
OTHER, "Successor", Optional.of(NOW.minusSeconds(60)), Optional.empty(),
TyrantClass.FIXER, Optional.of(VIGILANTE), Map.of(), Set.of(),
java.util.List.of()
);
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.of(VIGILANTE),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
5, 0, Set.of(TyrantUnlock.FIXER)
);
PersistentState state = new PersistentState(game, Map.of(
TYRANT, staleTyrant, VIGILANTE, active(VIGILANTE), OTHER, successor
));
LifecycleState maintained = service.maintain(state, Set.of(OTHER), NOW);
assertEquals(0, maintained.game().tyrantLevel());
assertEquals(1, maintained.game().unspentChoices());
assertEquals(Set.of(), maintained.game().purchases());
assertEquals(TyrantClass.NONE, maintained.players().get(OTHER).tyrantClass());
assertEquals(Optional.empty(), maintained.players().get(OTHER).followerOf());
}
private static GameState game(
Optional<UUID> tyrant,
Optional<UUID> vigilante,
Optional<PendingSelection> pendingTyrant,
Optional<PendingSelection> pendingVigilante
) {
return new GameState(
GameLifecycle.RUNNING, tyrant, vigilante, pendingTyrant, pendingVigilante,
Optional.empty(), Duration.ZERO, 1, 0, Set.of()
);
}
private static Map<UUID, PlayerState> players(PlayerState... players) {
java.util.HashMap<UUID, PlayerState> map = new java.util.HashMap<>();
for (PlayerState player : players) {
map.put(player.playerId(), player);
}
return Map.copyOf(map);
}
private static PlayerState active(UUID id) {
return withLogin(PlayerState.newPlayer(id, id.toString()), NOW.minusSeconds(60));
}
private static PlayerState withLogin(PlayerState player, Instant login) {
return new PlayerState(
player.playerId(), player.latestName(), Optional.of(login), player.optedOutUntil(),
player.tyrantClass(), player.followerOf(), player.cooldownEnds(),
player.readyAbilityItems(), player.capturedMobs()
);
}
private static PlayerState withFollower(PlayerState player, UUID vigilante) {
return new PlayerState(
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
player.tyrantClass(), Optional.of(vigilante), player.cooldownEnds(),
player.readyAbilityItems(), player.capturedMobs()
);
}
}
@@ -0,0 +1,62 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.random.RandomGenerator;
import org.junit.jupiter.api.Test;
final class RoleRelinquishmentServiceTest {
@Test
void vigilanteCanRelinquishAndTriggerNormalReroll() {
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID vigilante = UUID.fromString("22222222-2222-2222-2222-222222222222");
UUID candidate = UUID.fromString("33333333-3333-3333-3333-333333333333");
Instant now = Instant.parse("2026-08-14T12:00:00Z");
RoleCandidateSelector selector = new RoleCandidateSelector();
RandomGenerator random = RandomGenerator.of("L64X128MixRandom");
TyrantSuccessionService succession = new TyrantSuccessionService(
selector, Duration.ofHours(24), Duration.ofHours(24), random
);
RoleMaintenanceService maintenance = new RoleMaintenanceService(
selector, succession, Duration.ofHours(48), Duration.ofHours(24),
Duration.ofHours(24), random
);
RoleRelinquishmentService service = new RoleRelinquishmentService(
succession, maintenance
);
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrant), Optional.of(vigilante),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
0, 0, Set.of()
);
PlayerState tyrantState = active(tyrant, now);
PlayerState vigilanteState = active(vigilante, now);
PlayerState candidateState = active(candidate, now);
RelinquishmentResult result = service.relinquish(
new PersistentState(game, Map.of(
tyrant, tyrantState, vigilante, vigilanteState, candidate, candidateState
)),
vigilante,
Set.of(tyrant, candidate),
now
);
assertEquals(RelinquishmentStatus.RELINQUISHED, result.status());
assertEquals(Optional.of(candidate), result.state().game().vigilanteId());
}
private static PlayerState active(UUID id, Instant now) {
PlayerState player = PlayerState.newPlayer(id, id.toString());
return new PlayerState(
id, player.latestName(), Optional.of(now.minusSeconds(60)), Optional.empty(),
TyrantClass.NONE, Optional.empty(), Map.of(), Set.of(), java.util.List.of()
);
}
}
@@ -0,0 +1,14 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
final class VigilantePowerTest {
@Test
void oneLevelPerNearbyFollowerIsCappedSafely() {
assertEquals(new VigilantePower.Levels(3, 3), VigilantePower.forFollowers(3, 5, 4));
assertEquals(new VigilantePower.Levels(5, 4), VigilantePower.forFollowers(10, 5, 4));
assertEquals(new VigilantePower.Levels(0, 0), VigilantePower.forFollowers(0, 5, 4));
}
}