diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java index c8070206..1b8031d1 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java +++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java @@ -96,6 +96,7 @@ import net.onelitefeather.cygnus.telemetry.CygnusAttributes; import net.onelitefeather.cygnus.telemetry.TraceCookie; import net.onelitefeather.cygnus.telemetry.TracingCreekWitness; +import net.onelitefeather.cygnus.creek.tab.HuntedTabWitness; import net.onelitefeather.cygnus.common.map.GameMap; import net.minestom.server.entity.Player; import org.jetbrains.annotations.Nullable; @@ -231,7 +232,8 @@ public Cygnus() { // either way, and it needs neither the resource pack nor the overlay gate to be heard. new DamageSoundService(this.gameConfig.damageSound(), System::currentTimeMillis), new CreekModule(this.gameConfig.creek(), this.teamService, this.mapProvider, - sanityService, new TracingCreekWitness(sanityService, this.actionTracer, Cygnus::survivorActor), this.jumpscareManager, this.staminaService, tickSections), + sanityService, new HuntedTabWitness(new TracingCreekWitness(sanityService, this.actionTracer, Cygnus::survivorActor), + MinecraftServer.getConnectionManager()::getOnlinePlayerByUuid), this.jumpscareManager, this.staminaService, tickSections), sanityService, new AdrenalineService( this.gameConfig.adrenaline(), diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java b/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java index 3d1e651b..035ec00b 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java @@ -7,14 +7,18 @@ import net.onelitefeather.cygnus.creek.state.CreekActions; import net.onelitefeather.cygnus.creek.state.CreekContext; import net.onelitefeather.cygnus.creek.state.CreekState; +import net.onelitefeather.cygnus.creek.state.HuntState; import net.onelitefeather.cygnus.creek.state.StalkState; import net.onelitefeather.cygnus.creek.state.SurvivorView; import net.onelitefeather.cygnus.creek.state.VanishState; import net.onelitefeather.cygnus.creek.world.RouteProvider; +import org.jetbrains.annotations.Nullable; + import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.UUID; @@ -26,7 +30,7 @@ *

* * @author theEvilReaper - * @version 1.0.0 + * @version 1.1.0 * @since 2.15.0 */ final class Creek { @@ -44,6 +48,7 @@ final class Creek { private List lastViews = List.of(); private Set seenLastStep = Set.of(); private boolean entered; + private @Nullable UUID hunting; /** * Sets up a creek. @@ -87,9 +92,12 @@ void tick(SurvivorSnapshot survivors, Set ignored, long now) { if (!this.entered) { this.state.enter(ctx); this.entered = true; + // A hunt the creek starts in may be over within this very step. + this.syncHunt(); } CreekState next = this.state.tick(ctx); if (next != this.state) this.switchTo(next, ctx); + this.syncHunt(); // Reported after the step, so a stalk that just ended or turned into a hunt is not counted. if (this.state instanceof StalkState stalk) this.round.witness().stalked(stalk.target()); } @@ -106,6 +114,20 @@ void vanishForGood(SurvivorSnapshot survivors, long now) { // The state it had so far is never entered now, and never needs to be. this.entered = true; this.switchTo(VanishState.forever(), ctx); + this.syncHunt(); + } + + /** + * Tells the witness when the survivor this creek hunts changes: a hunt that starts, one that + * ends, or a switch to another target. Called after every step, so each hunt is reported once. + */ + private void syncHunt() { + UUID now = this.state instanceof HuntState hunt ? hunt.target() : null; + UUID before = this.hunting; + if (Objects.equals(now, before)) return; + this.hunting = now; + if (before != null) this.round.witness().huntEnded(before); + if (now != null) this.round.witness().hunted(now); } /** @@ -195,6 +217,9 @@ RouteProvider route() { } void remove() { + UUID before = this.hunting; + this.hunting = null; + if (before != null) this.round.witness().huntEnded(before); this.body.remove(); } diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/dread/CreekWitness.java b/game/src/main/java/net/onelitefeather/cygnus/creek/dread/CreekWitness.java index 7e94faaf..2339bc5a 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/dread/CreekWitness.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/dread/CreekWitness.java @@ -10,7 +10,7 @@ *

* * @author theEvilReaper - * @version 1.0.0 + * @version 1.1.0 * @since 2.16.0 */ public interface CreekWitness { @@ -68,4 +68,26 @@ public void selected(UUID survivor) { * @param survivor the survivor who was picked out */ void selected(UUID survivor); + + /** + * A creek has started to hunt a survivor, the chase that ends in a catch. Called once, when the + * creek takes the survivor as its target. A stalk is not a hunt: it only shadows the survivor. + * + * @param survivor the survivor being hunted + * @since 2.15.0 + */ + default void hunted(UUID survivor) { + // Nobody listens by default. + } + + /** + * A creek stopped hunting a survivor, however the hunt ended: a catch, time running out, the + * survivor being gone, the creek being sent away or removed. Called once per {@link #hunted(UUID)}. + * + * @param survivor the survivor who is no longer hunted + * @since 2.15.0 + */ + default void huntEnded(UUID survivor) { + // Nobody listens by default. + } } diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/tab/HuntedTabWitness.java b/game/src/main/java/net/onelitefeather/cygnus/creek/tab/HuntedTabWitness.java new file mode 100644 index 00000000..6b7d67b1 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/tab/HuntedTabWitness.java @@ -0,0 +1,140 @@ +package net.onelitefeather.cygnus.creek.tab; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minestom.server.entity.Player; +import net.onelitefeather.cygnus.creek.dread.CreekWitness; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +/** + * Marks the survivors a creek is hunting in the tab list, and takes the mark away when the hunt is over. + *

+ * It decorates the {@link CreekWitness}, so the creek needs no knowledge of the tab list. The mark is + * composed on top of whatever display name the survivor carries (role icon, name color) instead of + * replacing it: the name turns {@link NamedTextColor#RED} and a {@value #MARKER} marker goes behind it, after a space. + * Glyphs from the icon font keep their own color. + *

+ *

+ * The display name from before is kept and put back exactly, but only if the survivor still shows the + * marked name. If anything else renamed them in the meantime (a spectator's struck-through name, the + * rank tag of the restart lobby), that newer name wins and nothing is restored over it. Two creeks on + * one survivor are counted, so the mark stays until the last hunt ends. + *

+ *

+ * The display name is global, so every player sees the mark in the tab list, not only the hunted one. + *

+ * + * @author TheMeinerLP + * @version 1.1.0 + * @since 2.15.0 + */ +public final class HuntedTabWitness implements CreekWitness { + + /** The marker behind a hunted survivor's name. */ + public static final String MARKER = "◆"; + + private final CreekWitness delegate; + private final Function players; + private final Map marks = new ConcurrentHashMap<>(); + + /** + * Creates the decorator. + * + * @param delegate the witness that still has to hear everything + * @param players finds an online player by id, or {@code null} if they are gone + */ + public HuntedTabWitness(CreekWitness delegate, Function players) { + this.delegate = delegate; + this.players = players; + } + + /** + * Builds the marked version of a display name. + * + * @param base the name as it is shown without the mark + * @return the red name with the marker behind it + */ + public static Component highlight(Component base) { + return Component.text() + .color(NamedTextColor.RED) + .append(recolor(base)) + .append(Component.text(" " + MARKER, NamedTextColor.DARK_RED)) + .build(); + } + + /** + * Turns every colored text red, except the icon glyphs, which are full-color bitmaps. + */ + private static Component recolor(Component component) { + Component out = component; + if (component.font() == null && component.color() != null) out = out.color(NamedTextColor.RED); + List children = component.children().stream().map(HuntedTabWitness::recolor).toList(); + return out.children(children); + } + + @Override + public void hunted(UUID survivor) { + this.delegate.hunted(survivor); + Player player = this.players.apply(survivor); + if (player == null) return; + this.marks.compute(survivor, (_, mark) -> { + if (mark != null) return mark.another(); + Component base = player.getDisplayName(); + Component shown = highlight(base != null ? base : Component.text(player.getUsername())); + player.setDisplayName(shown); + return new Mark(base, shown, 1); + }); + } + + @Override + public void huntEnded(UUID survivor) { + this.delegate.huntEnded(survivor); + this.marks.computeIfPresent(survivor, (_, mark) -> { + if (mark.hunts() > 1) return mark.fewer(); + Player player = this.players.apply(survivor); + if (player != null && mark.shown().equals(player.getDisplayName())) { + player.setDisplayName(mark.base()); + } + return null; + }); + } + + @Override + public void sighted(UUID survivor) { + this.delegate.sighted(survivor); + } + + @Override + public void caught(UUID survivor) { + this.delegate.caught(survivor); + } + + @Override + public void stalked(UUID survivor) { + this.delegate.stalked(survivor); + } + + @Override + public void selected(UUID survivor) { + this.delegate.selected(survivor); + } + + /** + * A survivor's name before the mark, the marked name, and how many creeks hunt them. + */ + private record Mark(@Nullable Component base, Component shown, int hunts) { + Mark another() { + return new Mark(this.base, this.shown, this.hunts + 1); + } + + Mark fewer() { + return new Mark(this.base, this.shown, this.hunts - 1); + } + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/tab/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/creek/tab/package-info.java new file mode 100644 index 00000000..8d918688 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/tab/package-info.java @@ -0,0 +1,7 @@ +/** + * What the creek's hunt looks like in the tab list. + */ +@NotNullByDefault +package net.onelitefeather.cygnus.creek.tab; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/game/src/main/java/net/onelitefeather/cygnus/telemetry/TracingCreekWitness.java b/game/src/main/java/net/onelitefeather/cygnus/telemetry/TracingCreekWitness.java index 999b8a12..8f5e6778 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/telemetry/TracingCreekWitness.java +++ b/game/src/main/java/net/onelitefeather/cygnus/telemetry/TracingCreekWitness.java @@ -21,7 +21,7 @@ *

* * @author TheMeinerLP - * @version 1.0.0 + * @version 1.1.0 * @since 2.15.0 */ public final class TracingCreekWitness implements CreekWitness { @@ -76,6 +76,16 @@ public void selected(UUID survivor) { this.delegate.selected(survivor); } + @Override + public void hunted(UUID survivor) { + this.delegate.hunted(survivor); + } + + @Override + public void huntEnded(UUID survivor) { + this.delegate.huntEnded(survivor); + } + /** * A tracing failure must never reach the creek, which is the part of the game that is being * observed. diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java index b5744cc2..66bc9427 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java @@ -22,6 +22,9 @@ import net.onelitefeather.cygnus.creek.state.SurvivorView; import net.onelitefeather.cygnus.creek.state.VanishState; import net.onelitefeather.cygnus.creek.state.PatrolState; +import net.onelitefeather.cygnus.creek.tab.HuntedTabWitness; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import net.onelitefeather.cygnus.creek.world.CreekSight; import net.onelitefeather.cygnus.creek.world.SpotFinder; import org.junit.jupiter.api.DisplayName; @@ -61,6 +64,18 @@ private static final class RecordingWitness implements CreekWitness { private final List catches = new ArrayList<>(); private final List stalks = new ArrayList<>(); private final List selections = new ArrayList<>(); + private final List hunts = new ArrayList<>(); + private final List huntEnds = new ArrayList<>(); + + @Override + public void hunted(UUID survivor) { + this.hunts.add(survivor); + } + + @Override + public void huntEnded(UUID survivor) { + this.huntEnds.add(survivor); + } @Override public void sighted(UUID survivor) { @@ -323,6 +338,133 @@ void ignoredSurvivorsAreNotReported(Env env) { assertTrue(witness.sightings.isEmpty()); } + @Test + @DisplayName("A hunt is reported once when it starts, not for every step") + void huntIsReportedOnce(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0, 0, 0)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 20)); + RecordingWitness witness = new RecordingWitness(); + Creek creek = creek(body, CreekConfig.DEFAULT, new HuntState(survivor.getUuid(), Long.MAX_VALUE), witness); + + creek.tick(survivors(survivor), 0L); + creek.tick(survivors(survivor), 100L); + + assertEquals(List.of(survivor.getUuid()), witness.hunts); + assertTrue(witness.huntEnds.isEmpty()); + } + + @Test + @DisplayName("A stalk is not a hunt") + void stalkIsNotAHunt(Env env) { + Instance instance = env.createFlatInstance(); + Player target = env.createConnection().connect(instance, new Pos(0, 40, 0, 180, 0)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 25)); + RecordingWitness witness = new RecordingWitness(); + Creek creek = creek(body, CreekConfig.DEFAULT, new StalkState(target.getUuid(), Long.MAX_VALUE), witness); + + creek.tick(survivors(target), 0L); + + assertTrue(witness.hunts.isEmpty()); + } + + @Test + @DisplayName("A catch ends the hunt") + void catchEndsTheHunt(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0, 180, 0)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 1)); + RecordingWitness witness = new RecordingWitness(); + Creek creek = creek(body, CreekConfig.DEFAULT, new HuntState(survivor.getUuid(), Long.MAX_VALUE), witness); + + creek.tick(survivors(survivor), 0L); + + assertEquals(List.of(survivor.getUuid()), witness.hunts); + assertEquals(List.of(survivor.getUuid()), witness.huntEnds); + } + + @Test + @DisplayName("A hunt that runs out of time ends") + void timeoutEndsTheHunt(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0, 0, 0)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 20)); + RecordingWitness witness = new RecordingWitness(); + Creek creek = creek(body, CreekConfig.DEFAULT, new HuntState(survivor.getUuid(), 500L), witness); + creek.tick(survivors(survivor), 0L); + + creek.tick(survivors(survivor), 600L); + + assertEquals(List.of(survivor.getUuid()), witness.huntEnds); + } + + @Test + @DisplayName("A survivor who disappears from the round ends the hunt") + void goneSurvivorEndsTheHunt(Env env) { + Instance instance = env.createFlatInstance(); + Player target = env.createConnection().connect(instance, new Pos(0, 40, 0, 0, 0)); + Player other = env.createConnection().connect(instance, new Pos(5, 40, 0, 0, 0)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 10)); + RecordingWitness witness = new RecordingWitness(); + Creek creek = creek(body, CreekConfig.DEFAULT, new HuntState(target.getUuid(), Long.MAX_VALUE), witness); + creek.tick(survivors(target, other), 0L); + + creek.tick(survivors(other), 100L); + + assertEquals(List.of(target.getUuid()), witness.huntEnds); + } + + @Test + @DisplayName("Removing a creek in the middle of a hunt ends it") + void removingEndsTheHunt(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0, 0, 0)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 20)); + RecordingWitness witness = new RecordingWitness(); + Creek creek = creek(body, CreekConfig.DEFAULT, new HuntState(survivor.getUuid(), Long.MAX_VALUE), witness); + creek.tick(survivors(survivor), 0L); + + creek.remove(); + creek.remove(); + + assertEquals(List.of(survivor.getUuid()), witness.huntEnds); + } + + @Test + @DisplayName("Sending a hunting creek away for good ends the hunt") + void vanishingEndsTheHunt(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0, 0, 0)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 20)); + RecordingWitness witness = new RecordingWitness(); + Creek creek = creek(body, CreekConfig.DEFAULT, new HuntState(survivor.getUuid(), Long.MAX_VALUE), witness); + creek.tick(survivors(survivor), 0L); + + creek.vanishForGood(survivors(survivor), 100L); + + assertEquals(List.of(survivor.getUuid()), witness.huntEnds); + } + + @Test + @DisplayName("Through the creek, the tab name is marked during the hunt and back to normal after the catch") + void tabNameFollowsTheHunt(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0, 180, 0)); + Component before = Component.text(survivor.getUsername()); + survivor.setDisplayName(before); + CreakingBody far = CreakingBody.spawn(instance, new Pos(0, 40, 20)); + HuntedTabWitness witness = new HuntedTabWitness(CreekWitness.NONE, + id -> id.equals(survivor.getUuid()) ? survivor : null); + Creek hunting = creek(far, CreekConfig.DEFAULT, new HuntState(survivor.getUuid(), Long.MAX_VALUE), witness); + + hunting.tick(survivors(survivor), 0L); + assertTrue(PlainTextComponentSerializer.plainText().serialize(survivor.getDisplayName()) + .contains(HuntedTabWitness.MARKER)); + + hunting.remove(); + assertEquals(before, survivor.getDisplayName()); + } + @Test @DisplayName("A catch is reported") void catchIsReported(Env env) { diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/tab/HuntedTabWitnessTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/tab/HuntedTabWitnessTest.java new file mode 100644 index 00000000..bc8e6e76 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/tab/HuntedTabWitnessTest.java @@ -0,0 +1,268 @@ +package net.onelitefeather.cygnus.creek.tab; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.creek.dread.CreekWitness; +import net.onelitefeather.cygnus.team.RoleIcon; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HuntedTabWitnessTest extends CygnusPlayerTestBase { + + private static String plain(Component component) { + return PlainTextComponentSerializer.plainText().serialize(component); + } + + private static Player join(Env env, Instance instance) { + return env.createConnection().connect(instance, new Pos(0, 40, 0)); + } + + private static HuntedTabWitness witness(Player... players) { + Map byId = new HashMap<>(); + for (Player player : players) byId.put(player.getUuid(), player); + return new HuntedTabWitness(CreekWitness.NONE, byId::get); + } + + private static Component survivorName(Player player) { + return RoleIcon.SURVIVOR.prefix(Component.text(player.getUsername(), NamedTextColor.GREEN)); + } + + @Test + @DisplayName("A hunted survivor's tab name carries the marker and the name turns red") + void huntedSurvivorIsHighlighted(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = join(env, instance); + survivor.setDisplayName(survivorName(survivor)); + + witness(survivor).hunted(survivor.getUuid()); + + Component shown = survivor.getDisplayName(); + assertTrue(plain(shown).contains(HuntedTabWitness.MARKER), "the marker is missing: " + plain(shown)); + assertTrue(plain(shown).contains(survivor.getUsername()), "the name is missing: " + plain(shown)); + assertNotEquals(survivorName(survivor), shown, "the display name was not changed"); + } + + @Test + @DisplayName("The marker comes behind the name, not in front of it") + void markerIsBehindTheName(Env env) { + Component marked = HuntedTabWitness.highlight(RoleIcon.SURVIVOR.prefix(Component.text("Alex", NamedTextColor.GREEN))); + + String text = plain(marked); + assertTrue(text.endsWith("Alex " + HuntedTabWitness.MARKER), "the marker must be last: " + text); + Component last = marked.children().getLast(); + assertEquals(" " + HuntedTabWitness.MARKER, ((net.kyori.adventure.text.TextComponent) last).content()); + assertEquals(NamedTextColor.DARK_RED, last.color()); + } + + @Test + @DisplayName("The role icon keeps its own font and color under the highlight") + void roleIconSurvivesTheHighlight(Env env) { + Component marked = HuntedTabWitness.highlight(RoleIcon.SURVIVOR.prefix(Component.text("Alex", NamedTextColor.GREEN))); + + assertTrue(contains(marked, RoleIcon.SURVIVOR.glyph()), "the role glyph must be untouched"); + assertTrue(containsRedText(marked, "Alex"), "the name must be red"); + } + + @Test + @DisplayName("A spectator's strike-through stays when a highlighted name is composed") + void strikeThroughIsKept(Env env) { + Component struck = Component.text("Alex", NamedTextColor.GRAY, TextDecoration.STRIKETHROUGH); + + Component marked = HuntedTabWitness.highlight(struck); + + assertTrue(hasDecoration(marked, "Alex"), "the strike-through must survive"); + } + + @Test + @DisplayName("When the hunt ends the exact previous display name is back") + void huntEndRestoresTheName(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = join(env, instance); + Component before = survivorName(survivor); + survivor.setDisplayName(before); + HuntedTabWitness witness = witness(survivor); + witness.hunted(survivor.getUuid()); + + witness.huntEnded(survivor.getUuid()); + + assertEquals(before, survivor.getDisplayName()); + } + + @Test + @DisplayName("A survivor without a display name gets none again after the hunt") + void noDisplayNameStaysNone(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = join(env, instance); + survivor.setDisplayName(null); + HuntedTabWitness witness = witness(survivor); + + witness.hunted(survivor.getUuid()); + assertTrue(plain(survivor.getDisplayName()).contains(HuntedTabWitness.MARKER)); + witness.huntEnded(survivor.getUuid()); + + assertNull(survivor.getDisplayName()); + } + + @Test + @DisplayName("A spectator name set during the hunt is not overwritten when the hunt ends") + void spectatorNameWins(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = join(env, instance); + survivor.setDisplayName(survivorName(survivor)); + HuntedTabWitness witness = witness(survivor); + witness.hunted(survivor.getUuid()); + Component spectator = RoleIcon.SPECTATOR.prefix( + Component.text(survivor.getUsername(), NamedTextColor.GRAY, TextDecoration.STRIKETHROUGH)); + survivor.setDisplayName(spectator); + + witness.huntEnded(survivor.getUuid()); + + assertEquals(spectator, survivor.getDisplayName()); + } + + @Test + @DisplayName("Ending a hunt that never started changes nothing") + void endWithoutStartIsHarmless(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = join(env, instance); + Component before = survivorName(survivor); + survivor.setDisplayName(before); + + witness(survivor).huntEnded(survivor.getUuid()); + + assertEquals(before, survivor.getDisplayName()); + } + + @Test + @DisplayName("Two hunters on one survivor keep the mark until the last hunt ends") + void twoHuntsOnOneSurvivor(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = join(env, instance); + Component before = survivorName(survivor); + survivor.setDisplayName(before); + HuntedTabWitness witness = witness(survivor); + witness.hunted(survivor.getUuid()); + witness.hunted(survivor.getUuid()); + + witness.huntEnded(survivor.getUuid()); + assertTrue(plain(survivor.getDisplayName()).contains(HuntedTabWitness.MARKER), "still hunted by the second creek"); + witness.huntEnded(survivor.getUuid()); + + assertEquals(before, survivor.getDisplayName()); + } + + @Test + @DisplayName("Two hunted survivors are marked and restored independently") + void twoTargets(Env env) { + Instance instance = env.createFlatInstance(); + Player first = join(env, instance); + Player second = join(env, instance); + Component firstBefore = survivorName(first); + Component secondBefore = survivorName(second); + first.setDisplayName(firstBefore); + second.setDisplayName(secondBefore); + HuntedTabWitness witness = witness(first, second); + witness.hunted(first.getUuid()); + witness.hunted(second.getUuid()); + + witness.huntEnded(first.getUuid()); + + assertEquals(firstBefore, first.getDisplayName()); + assertTrue(plain(second.getDisplayName()).contains(HuntedTabWitness.MARKER), "the second is still hunted"); + witness.huntEnded(second.getUuid()); + assertEquals(secondBefore, second.getDisplayName()); + } + + @Test + @DisplayName("A hunted survivor who left is skipped without error") + void goneSurvivorIsSkipped(Env env) { + HuntedTabWitness witness = witness(); + UUID gone = UUID.randomUUID(); + + witness.hunted(gone); + witness.huntEnded(gone); + } + + @Test + @DisplayName("Everything else the creek reports still reaches the wrapped witness") + void delegatesTheRest(Env env) { + UUID id = UUID.randomUUID(); + StringBuilder log = new StringBuilder(); + CreekWitness recording = new CreekWitness() { + @Override + public void sighted(UUID survivor) { + log.append("sighted;"); + } + + @Override + public void caught(UUID survivor) { + log.append("caught;"); + } + + @Override + public void stalked(UUID survivor) { + log.append("stalked;"); + } + + @Override + public void selected(UUID survivor) { + log.append("selected;"); + } + + @Override + public void hunted(UUID survivor) { + log.append("hunted;"); + } + + @Override + public void huntEnded(UUID survivor) { + log.append("ended;"); + } + }; + HuntedTabWitness witness = new HuntedTabWitness(recording, _ -> null); + + witness.sighted(id); + witness.caught(id); + witness.stalked(id); + witness.selected(id); + witness.hunted(id); + witness.huntEnded(id); + + assertEquals("sighted;caught;stalked;selected;hunted;ended;", log.toString()); + } + + private static boolean contains(Component root, Component wanted) { + if (root.equals(wanted)) return true; + return root.children().stream().anyMatch(child -> contains(child, wanted)); + } + + private static boolean containsRedText(Component root, String text) { + if (root instanceof net.kyori.adventure.text.TextComponent t && t.content().equals(text)) { + return NamedTextColor.RED.equals(t.color()); + } + return root.children().stream().anyMatch(child -> containsRedText(child, text)); + } + + private static boolean hasDecoration(Component root, String text) { + if (root instanceof net.kyori.adventure.text.TextComponent t && t.content().equals(text)) { + return t.decoration(TextDecoration.STRIKETHROUGH) == TextDecoration.State.TRUE; + } + return root.children().stream().anyMatch(child -> hasDecoration(child, text)); + } +}