diff --git a/design/log.md b/design/log.md index d384dd0..86dd390 100644 --- a/design/log.md +++ b/design/log.md @@ -6,6 +6,18 @@ description: Chronological record of material decisions affecting the Spigot Tyr # Spigot Tyrant Design Log +## 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. diff --git a/design/user-stories/us-011-inform-and-manage-participation.md b/design/user-stories/us-011-inform-and-manage-participation.md index 9f122d7..1bf5fe0 100644 --- a/design/user-stories/us-011-inform-and-manage-participation.md +++ b/design/user-stories/us-011-inform-and-manage-participation.md @@ -20,6 +20,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 diff --git a/src/main/java/games/dmg/spigottyrant/ParticipationCombatListener.java b/src/main/java/games/dmg/spigottyrant/ParticipationCombatListener.java new file mode 100644 index 0000000..df43505 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/ParticipationCombatListener.java @@ -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 attacker = attackingPlayer(event); + if (attacker.isEmpty()) { + return; + } + Player source = attacker.orElseThrow(); + Map 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 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(); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/ParticipationCombatService.java b/src/main/java/games/dmg/spigottyrant/ParticipationCombatService.java new file mode 100644 index 0000000..5e7987e --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/ParticipationCombatService.java @@ -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; + } +} diff --git a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java index 9f7d630..49c33f8 100644 --- a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java +++ b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java @@ -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() diff --git a/src/test/java/games/dmg/spigottyrant/ParticipationCombatListenerTest.java b/src/test/java/games/dmg/spigottyrant/ParticipationCombatListenerTest.java new file mode 100644 index 0000000..c01d59a --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/ParticipationCombatListenerTest.java @@ -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() + ); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/ParticipationCombatServiceTest.java b/src/test/java/games/dmg/spigottyrant/ParticipationCombatServiceTest.java new file mode 100644 index 0000000..10d903c --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/ParticipationCombatServiceTest.java @@ -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 pausedAt) { + return new GameState( + lifecycle, Optional.of(TYRANT), Optional.empty(), Optional.empty(), + Optional.empty(), pausedAt, Duration.ZERO, 0, 0, Set.of() + ); + } +}