Compare commits

..
2 Commits
Author SHA1 Message Date
dmg ea267ff867 fix(status): simplify Tyrant join messaging
Release / release (push) Successful in 2m36s
CI / build (push) Successful in 1m5s
2026-08-15 09:09:06 -04:00
dmg 24c54a7281 feat(participation): isolate opted-out combat
Release / release (push) Successful in 2m24s
CI / build (push) Successful in 1m4s
2026-08-14 23:33:24 -04:00
9 changed files with 297 additions and 6 deletions
+23
View File
@@ -6,6 +6,29 @@ description: Chronological record of material decisions affecting the Spigot Tyr
# Spigot Tyrant Design Log
## 2026-08-14 — Join messaging refinement completed
- Updated US-011 so join output no longer announces running, paused, or unstarted lifecycle state.
- Eligible unassigned players now see the neutral prompt, `Use /tyrant optout if you do not want to participate in Tyrant events.`
- Role, class, ability, cooldown, and existing opt-out details remain unchanged.
- Verified both assigned and unassigned join output with automated tests.
## 2026-08-14 — Join messaging refinement started
- US-011 is reopened to remove lifecycle announcements from join output and make the opt-out prompt refer generally to Tyrant events.
## 2026-08-14 — Opt-out combat protection completed
- Extended US-011 with bidirectional combat isolation between opted-out players and the Tyrant, Assassin, Fixer, or Tamer while the event is running.
- Direct player attacks and player-fired projectiles are cancelled at highest event priority, and the attacker is told that the target has chosen not to participate in the Tyrant event.
- Paused, unstarted, and unrelated combat remains ordinary Minecraft gameplay.
- Verified both attack directions, every Tyrant-side class, projectile attribution, lifecycle gating, and unrelated combat with automated tests.
## 2026-08-14 — Opt-out combat protection started
- US-011 is reopened for test-first running-event combat isolation between opted-out players and the Tyrant or Tyrant-side class holders.
- Paused and unstarted gameplay remains ordinary Minecraft combat; blocked direct and projectile attackers receive a concise Tyrant event participation message.
## 2026-08-14 — Tamer completed
- Completed US-007 with the no-cooldown owner-bound Tamer's Lead, server-generated entity snapshots, durable capture IDs, and one bound Captured Mob item per entity.
@@ -11,7 +11,8 @@ As a **player**, I want to understand my status and control my eligibility so th
## Acceptance criteria
- [x] On join, a player is told the game status and their role, class, subclass, abilities, and relevant cooldowns.
- [x] On join, lifecycle state is omitted while role, class, subclass, ability, and relevant cooldown information remains available when applicable.
- [x] Eligible players are invited to opt out of Tyrant events without implying that an event is currently running.
- [x] An unassigned opted-in player is told that they are eligible and shown the opt-out command.
- [x] An opted-out player is told when they may opt back in.
- [x] An unassigned player can opt out through a command.
@@ -20,6 +21,11 @@ As a **player**, I want to understand my status and control my eligibility so th
- [x] A player cannot opt back in until seven days have elapsed by default.
- [x] The opt-out duration is configurable, uses unpaused elapsed time, and survives restart.
- [x] Participation commands are idempotent and clearly report current status and remaining time.
- [x] While the event is running, damage is cancelled when the Tyrant or an Assassin, Fixer, or Tamer attacks an opted-out player.
- [x] While the event is running, damage is cancelled when an opted-out player attacks the Tyrant or an Assassin, Fixer, or Tamer.
- [x] Cancelled attackers are told that the target has chosen not to take part in the Tyrant event.
- [x] Protection covers direct player attacks and player-fired projectiles without changing unrelated combat.
- [x] Participation combat protection does not apply while the event is paused or not started.
## Related
@@ -0,0 +1,64 @@
package games.dmg.spigottyrant;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.entity.Player;
import org.bukkit.entity.Projectile;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
public final class ParticipationCombatListener implements Listener {
public static final String OPTED_OUT_MESSAGE =
"That player has chosen not to take part in the Tyrant event.";
private final TyrantStateManager stateManager;
private final ParticipationCombatService combat;
public ParticipationCombatListener(
TyrantStateManager stateManager,
ParticipationCombatService combat
) {
this.stateManager = stateManager;
this.combat = combat;
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onDamage(EntityDamageByEntityEvent event) {
if (!(event.getEntity() instanceof Player target)) {
return;
}
Optional<Player> attacker = attackingPlayer(event);
if (attacker.isEmpty()) {
return;
}
Player source = attacker.orElseThrow();
Map<UUID, PlayerState> players = stateManager.players();
PlayerState attackerState = players.getOrDefault(
source.getUniqueId(),
PlayerState.newPlayer(source.getUniqueId(), source.getName())
);
PlayerState targetState = players.getOrDefault(
target.getUniqueId(),
PlayerState.newPlayer(target.getUniqueId(), target.getName())
);
if (combat.blocks(stateManager.game(), attackerState, targetState)) {
event.setCancelled(true);
source.sendMessage(ChatColor.YELLOW + OPTED_OUT_MESSAGE);
}
}
private static Optional<Player> attackingPlayer(EntityDamageByEntityEvent event) {
if (event.getDamager() instanceof Player player) {
return Optional.of(player);
}
if (event.getDamager() instanceof Projectile projectile
&& projectile.getShooter() instanceof Player player) {
return Optional.of(player);
}
return Optional.empty();
}
}
@@ -0,0 +1,24 @@
package games.dmg.spigottyrant;
public final class ParticipationCombatService {
public boolean blocks(
GameState game,
PlayerState attacker,
PlayerState target
) {
if (game.lifecycle() != GameLifecycle.RUNNING) {
return false;
}
boolean attackerOptedOut = attacker.optedOutUntil().isPresent();
boolean targetOptedOut = target.optedOutUntil().isPresent();
boolean attackerTyrantSide = isTyrantSide(game, attacker);
boolean targetTyrantSide = isTyrantSide(game, target);
return (attackerTyrantSide && targetOptedOut)
|| (attackerOptedOut && targetTyrantSide);
}
private static boolean isTyrantSide(GameState game, PlayerState player) {
return game.tyrantId().filter(player.playerId()::equals).isPresent()
|| player.tyrantClass() != TyrantClass.NONE;
}
}
@@ -15,7 +15,6 @@ public final class PlayerStatusMessages {
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 "
@@ -45,8 +44,8 @@ public final class PlayerStatusMessages {
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.");
messages.add("Use /tyrant optout if you do not want to participate "
+ "in Tyrant events.");
}
return List.copyOf(messages);
}
@@ -122,6 +122,12 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
),
this
);
getServer().getPluginManager().registerEvents(
new ParticipationCombatListener(
stateManager, new ParticipationCombatService()
),
this
);
getServer().getPluginManager().registerEvents(
new VigilanteCombatListener(
stateManager, combatTracker, clock, settings.vigilanteCombatDuration()
@@ -0,0 +1,75 @@
package games.dmg.spigottyrant;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
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.bukkit.ChatColor;
import org.bukkit.entity.Player;
import org.bukkit.entity.Projectile;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.junit.jupiter.api.Test;
final class ParticipationCombatListenerTest {
private static final UUID TYRANT = UUID.fromString(
"11111111-1111-1111-1111-111111111111"
);
private static final UUID OUT = UUID.fromString(
"22222222-2222-2222-2222-222222222222"
);
@Test
void cancelsPlayerProjectileAndMessagesTheAttacker() {
TyrantStateManager manager = mock(TyrantStateManager.class);
Player attacker = player(TYRANT, "Tyrant");
Player target = player(OUT, "Out");
Projectile projectile = mock(Projectile.class);
EntityDamageByEntityEvent event = mock(EntityDamageByEntityEvent.class);
when(projectile.getShooter()).thenReturn(attacker);
when(event.getDamager()).thenReturn(projectile);
when(event.getEntity()).thenReturn(target);
when(manager.game()).thenReturn(running());
when(manager.players()).thenReturn(Map.of(
TYRANT, state(TYRANT, TyrantClass.NONE, false),
OUT, state(OUT, TyrantClass.NONE, true)
));
ParticipationCombatListener listener = new ParticipationCombatListener(
manager, new ParticipationCombatService()
);
listener.onDamage(event);
verify(event).setCancelled(true);
verify(attacker).sendMessage(ChatColor.YELLOW
+ ParticipationCombatListener.OPTED_OUT_MESSAGE);
}
private static Player player(UUID id, String name) {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
when(player.getName()).thenReturn(name);
return player;
}
private static PlayerState state(UUID id, TyrantClass role, boolean optedOut) {
return new PlayerState(
id, id.toString(), Optional.empty(),
optedOut ? Optional.of(Instant.parse("2026-08-21T00:00:00Z")) : Optional.empty(),
role, Optional.empty(), Map.of(), Set.of(), java.util.List.of()
);
}
private static GameState running() {
return new GameState(
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
0, 0, Set.of()
);
}
}
@@ -0,0 +1,90 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
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 ParticipationCombatServiceTest {
private static final UUID TYRANT = UUID.fromString(
"11111111-1111-1111-1111-111111111111"
);
private static final UUID OUTSIDER = UUID.fromString(
"22222222-2222-2222-2222-222222222222"
);
private static final UUID SUPPORTER = UUID.fromString(
"33333333-3333-3333-3333-333333333333"
);
private final ParticipationCombatService service = new ParticipationCombatService();
@Test
void blocksDamageInBothDirectionsBetweenOptedOutPlayerAndTyrantSide() {
PlayerState optedOut = player(OUTSIDER, TyrantClass.NONE, true);
PlayerState tyrant = player(TYRANT, TyrantClass.NONE, false);
PlayerState supporter = player(SUPPORTER, TyrantClass.ASSASSIN, false);
assertTrue(service.blocks(running(), tyrant, optedOut));
assertTrue(service.blocks(running(), optedOut, tyrant));
assertTrue(service.blocks(running(), supporter, optedOut));
assertTrue(service.blocks(running(), optedOut, supporter));
}
@Test
void recognizesEveryTyrantSideClassAsSupport() {
PlayerState optedOut = player(OUTSIDER, TyrantClass.NONE, true);
assertTrue(service.blocks(
running(), player(SUPPORTER, TyrantClass.ASSASSIN, false), optedOut
));
assertTrue(service.blocks(
running(), player(SUPPORTER, TyrantClass.FIXER, false), optedOut
));
assertTrue(service.blocks(
running(), player(SUPPORTER, TyrantClass.TAMER, false), optedOut
));
}
@Test
void leavesPausedUnstartedAndUnrelatedCombatAlone() {
PlayerState optedOut = player(OUTSIDER, TyrantClass.NONE, true);
PlayerState tyrant = player(TYRANT, TyrantClass.NONE, false);
PlayerState ordinary = player(SUPPORTER, TyrantClass.NONE, false);
assertFalse(service.blocks(paused(), tyrant, optedOut));
assertFalse(service.blocks(GameState.empty(), tyrant, optedOut));
assertFalse(service.blocks(running(), tyrant, ordinary));
assertFalse(service.blocks(running(), ordinary, optedOut));
}
private static PlayerState player(UUID id, TyrantClass role, boolean optedOut) {
return new PlayerState(
id, id.toString(), Optional.empty(),
optedOut ? Optional.of(Instant.parse("2026-08-21T00:00:00Z")) : Optional.empty(),
role, Optional.empty(), Map.of(), Set.of(), java.util.List.of()
);
}
private static GameState running() {
return game(GameLifecycle.RUNNING, Optional.empty());
}
private static GameState paused() {
return game(
GameLifecycle.PAUSED,
Optional.of(Instant.parse("2026-08-14T00:00:00Z"))
);
}
private static GameState game(GameLifecycle lifecycle, Optional<Instant> pausedAt) {
return new GameState(
lifecycle, Optional.of(TYRANT), Optional.empty(), Optional.empty(),
Optional.empty(), pausedAt, Duration.ZERO, 0, 0, Set.of()
);
}
}
@@ -1,5 +1,6 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
@@ -29,7 +30,8 @@ final class PlayerStatusMessagesTest {
List<String> messages = PlayerStatusMessages.forJoin(game, player, NOW);
assertTrue(messages.stream().anyMatch(message -> message.contains("Game: RUNNING")));
assertFalse(messages.stream().anyMatch(message -> message.startsWith("Game:")));
assertFalse(messages.stream().anyMatch(message -> message.startsWith("Tyrant event:")));
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")
@@ -42,6 +44,8 @@ final class PlayerStatusMessagesTest {
GameState.empty(), PlayerState.newPlayer(PLAYER, "Player"), NOW
);
assertTrue(messages.stream().anyMatch(message -> message.contains("/tyrant optout")));
assertTrue(messages.contains(
"Use /tyrant optout if you do not want to participate in Tyrant events."
));
}
}