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));
+ }
+}