From b5b99f20293eef021617f01b8bfbd18e3333dc30 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 4 Oct 2026 20:43:33 +0200 Subject: [PATCH 1/3] feat(creek): highlight hunted survivors in the tab list --- .../net/onelitefeather/cygnus/Cygnus.java | 4 +- .../onelitefeather/cygnus/creek/Creek.java | 27 +- .../cygnus/creek/dread/CreekWitness.java | 24 +- .../cygnus/creek/tab/HuntedTabWitness.java | 140 ++++++++++ .../cygnus/creek/tab/package-info.java | 7 + .../cygnus/telemetry/TracingCreekWitness.java | 12 +- .../cygnus/creek/CreekIntegrationTest.java | 142 ++++++++++ .../creek/tab/HuntedTabWitnessTest.java | 256 ++++++++++++++++++ 8 files changed, 608 insertions(+), 4 deletions(-) create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/tab/HuntedTabWitness.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/tab/package-info.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/tab/HuntedTabWitnessTest.java 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..25d97504 --- /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 in front. + * 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.0.0 + * @since 2.15.0 + */ +public final class HuntedTabWitness implements CreekWitness { + + /** The marker in front of 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 in front + */ + public static Component highlight(Component base) { + return Component.text() + .color(NamedTextColor.RED) + .append(Component.text(MARKER + " ", NamedTextColor.DARK_RED)) + .append(recolor(base)) + .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..2dc20b19 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/tab/HuntedTabWitnessTest.java @@ -0,0 +1,256 @@ +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 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)); + } +} From 5dd9159862f2e85a1d8966dac5a18247a3479baa Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 4 Oct 2026 21:09:06 +0200 Subject: [PATCH 2/3] feat(creek): put the hunted marker behind the name --- .../cygnus/creek/tab/HuntedTabWitness.java | 10 +++++----- .../cygnus/creek/tab/HuntedTabWitnessTest.java | 12 ++++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) 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 index 25d97504..6b7d67b1 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/tab/HuntedTabWitness.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/tab/HuntedTabWitness.java @@ -17,7 +17,7 @@ *

* 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 in front. + * 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. *

*

@@ -31,12 +31,12 @@ *

* * @author TheMeinerLP - * @version 1.0.0 + * @version 1.1.0 * @since 2.15.0 */ public final class HuntedTabWitness implements CreekWitness { - /** The marker in front of a hunted survivor's name. */ + /** The marker behind a hunted survivor's name. */ public static final String MARKER = "◆"; private final CreekWitness delegate; @@ -58,13 +58,13 @@ public HuntedTabWitness(CreekWitness delegate, Function * 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 in front + * @return the red name with the marker behind it */ public static Component highlight(Component base) { return Component.text() .color(NamedTextColor.RED) - .append(Component.text(MARKER + " ", NamedTextColor.DARK_RED)) .append(recolor(base)) + .append(Component.text(" " + MARKER, NamedTextColor.DARK_RED)) .build(); } 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 index 2dc20b19..bc8e6e76 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/tab/HuntedTabWitnessTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/tab/HuntedTabWitnessTest.java @@ -58,6 +58,18 @@ void huntedSurvivorIsHighlighted(Env env) { 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) { From e95d3196ae7c710ddba9ad919c9ca2151c64bd35 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 4 Oct 2026 22:04:30 +0200 Subject: [PATCH 3/3] feat(telemetry): measure how long survivors are hunted A span per creek hunt with its outcome and duration, and cygnus.creek.hunt.duration / cygnus.creek.hunts metrics by outcome and map. --- docs/telemetry.md | 28 +++ .../net/onelitefeather/cygnus/Cygnus.java | 8 +- .../onelitefeather/cygnus/creek/Creek.java | 28 ++- .../cygnus/creek/dread/CreekWitness.java | 13 + .../cygnus/creek/dread/HuntEnd.java | 22 ++ .../cygnus/creek/state/HuntState.java | 23 +- .../cygnus/creek/tab/HuntedTabWitness.java | 13 +- .../cygnus/telemetry/ActionTracer.java | 4 +- .../cygnus/telemetry/CygnusAttributes.java | 19 +- .../cygnus/telemetry/HuntMetrics.java | 125 +++++++++ .../cygnus/telemetry/HuntTracer.java | 158 ++++++++++++ .../cygnus/telemetry/RoundTracer.java | 18 +- .../cygnus/telemetry/TracingCreekWitness.java | 30 ++- .../cygnus/creek/CreekIntegrationTest.java | 13 + .../cygnus/creek/state/HuntStateEndTest.java | 70 ++++++ .../cygnus/telemetry/HuntTracerTest.java | 237 ++++++++++++++++++ 16 files changed, 789 insertions(+), 20 deletions(-) create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/dread/HuntEnd.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/telemetry/HuntMetrics.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/telemetry/HuntTracer.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/state/HuntStateEndTest.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/telemetry/HuntTracerTest.java diff --git a/docs/telemetry.md b/docs/telemetry.md index 47de72cd..f4e575ce 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -176,6 +176,33 @@ histogram_quantile(0.99, sum by (le, cygnus_phase_name) (rate(cygnus_tick_durati and the mean cost of a section: `sum by (cygnus_tick_section_name) (rate(..._section_duration_milliseconds_sum[5m])) / sum by (cygnus_tick_section_name) (rate(..._section_duration_milliseconds_count[5m]))`. +## Hunts + +A hunt is the chase of one creek after one survivor. It gets a span of its own, `cygnus.action.creek.hunt`, and a +metric, so "how long are survivors hunted, and how does it end" can be answered per map. + +| Outcome | Meaning | +| --- | --- | +| `caught` | The creek caught the survivor (the catch is reported just before the hunt ends). | +| `timeout` | The hunt ran out of its maximum time. | +| `gone` | The survivor died, left or turned before it ended. | +| `escaped` | The creek was sent away for good while it hunted (only one survivor left). | +| `round_end` | The creek was removed, or the round ended with the hunt still going. Hunts still open when a round ends are closed as `round_end` before the phase span ends. | + +| Instrument | Kind | Unit | Attributes | +| --- | --- | --- | --- | +| `cygnus.creek.hunt.duration` | histogram | `ms` | `cygnus.creek.hunt.outcome`, `cygnus.map` | +| `cygnus.creek.hunts` | counter | `{hunt}` | `cygnus.creek.hunt.outcome`, `cygnus.map` | + +Buckets (ms): 500, 1000, 2500, 5000, 7500, 10000, 15000, 20000, 30000, 45000, 60000, 90000, 120000. There is no +player attribute on the metrics (one series per player); the UUID is on the span. In Prometheus: +`cygnus_creek_hunt_duration_milliseconds_bucket` and `cygnus_creek_hunts_total`, labels `cygnus_creek_hunt_outcome` +and `cygnus_map`. A hunt that starts while no round runs has no span but is still counted. + +The creek reports a hunt by the survivor only, not by itself. Two creeks hunting the same survivor are two spans, +and each end closes the oldest open one of that survivor: the count is right, which span belongs to which creek +may not be. + ## Shutdown `ServiceShutdown` ends the span before it calls `Runtime.exit`. The agent flushes its exporter from a JVM @@ -208,6 +235,7 @@ player at that moment as `cygnus.position.x/y/z` (rounded to 0.1), plus `cygnus. | `cygnus.action.blackout.player` | child of a blackout, one per player hit; the player attributes only | | `cygnus.action.sanity.threshold` | `cygnus.sanity.band.from`, `cygnus.sanity.band`, `cygnus.sanity.fear` (0 calm, 1 maximum), `cygnus.sanity.value` (1 - fear, as `/creek` shows it), `cygnus.sanity.source` (`page`, `sighting`, `caught`, `selected`, `death`, `stalk`) | | `cygnus.action.creek.sighted`, `.selected`, `.stalk` (first step of a stalk only), `.caught` | the survivor's attributes only | +| `cygnus.action.creek.hunt` | A hunt, from the creek taking the survivor as its target until it stops. Child of the phase it started in. The survivor's attributes at the start, `cygnus.map`, `cygnus.creek.hunt.outcome` (`caught`, `timeout`, `gone`, `escaped`, `round_end`) and `cygnus.creek.hunt.duration_ms`. | `cygnus.page.out_ms` counts from the moment the page was placed or moved on. A page that has to wait on its own spot (no other spot is free) is counted from the moment it was hidden. diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java index 7ce36efc..896794e1 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java +++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java @@ -106,6 +106,8 @@ import net.onelitefeather.cygnus.telemetry.RoundTracer; import net.onelitefeather.cygnus.telemetry.ShutdownTracer; import net.onelitefeather.cygnus.telemetry.SlowTickTracer; +import net.onelitefeather.cygnus.telemetry.HuntMetrics; +import net.onelitefeather.cygnus.telemetry.HuntTracer; import net.onelitefeather.cygnus.telemetry.TickMetrics; import net.onelitefeather.cygnus.telemetry.TickSections; import net.onelitefeather.cygnus.telemetry.TraceStep; @@ -121,7 +123,7 @@ /** * @author theEvilReaper - * @version 1.5.0 + * @version 1.6.0 * @since 1.0.0 **/ @SuppressWarnings("java:S3252") @@ -235,7 +237,9 @@ 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 HuntedTabWitness(new TracingCreekWitness(sanityService, this.actionTracer, Cygnus::survivorActor), + sanityService, new HuntedTabWitness(new TracingCreekWitness(sanityService, this.actionTracer, Cygnus::survivorActor, + new HuntTracer(this.roundTracer, Clock.systemUTC(), this::activeMapName, + Cygnus::survivorActor, HuntMetrics.fromGlobal(serviceVersion()))), MinecraftServer.getConnectionManager()::getOnlinePlayerByUuid), this.jumpscareManager, this.staminaService, tickSections), sanityService, new AdrenalineService( 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 035ec00b..37a2d484 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java @@ -4,6 +4,7 @@ import net.minestom.server.entity.Entity; import net.minestom.server.entity.Player; import net.onelitefeather.cygnus.creek.body.CreekBody; +import net.onelitefeather.cygnus.creek.dread.HuntEnd; import net.onelitefeather.cygnus.creek.state.CreekActions; import net.onelitefeather.cygnus.creek.state.CreekContext; import net.onelitefeather.cygnus.creek.state.CreekState; @@ -30,7 +31,7 @@ *

* * @author theEvilReaper - * @version 1.1.0 + * @version 1.2.0 * @since 2.15.0 */ final class Creek { @@ -49,6 +50,7 @@ final class Creek { private Set seenLastStep = Set.of(); private boolean entered; private @Nullable UUID hunting; + private @Nullable HuntState huntState; /** * Sets up a creek. @@ -93,11 +95,11 @@ void tick(SurvivorSnapshot survivors, Set ignored, long now) { this.state.enter(ctx); this.entered = true; // A hunt the creek starts in may be over within this very step. - this.syncHunt(); + this.syncHunt(HuntEnd.TIMEOUT); } CreekState next = this.state.tick(ctx); if (next != this.state) this.switchTo(next, ctx); - this.syncHunt(); + this.syncHunt(HuntEnd.TIMEOUT); // 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()); } @@ -114,19 +116,28 @@ 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(); + this.syncHunt(HuntEnd.SENT_AWAY); } /** * 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. + * + * @param otherwise how a hunt ended that did not say so itself; the state a hunt leaves behind + * knows whether it was a catch, a timeout or the survivor being gone */ - private void syncHunt() { - UUID now = this.state instanceof HuntState hunt ? hunt.target() : null; + private void syncHunt(HuntEnd otherwise) { + HuntState hunt = this.state instanceof HuntState current ? current : null; + UUID now = hunt == null ? null : hunt.target(); UUID before = this.hunting; if (Objects.equals(now, before)) return; + HuntState ended = this.huntState; this.hunting = now; - if (before != null) this.round.witness().huntEnded(before); + this.huntState = hunt; + if (before != null) { + HuntEnd how = ended != null && ended.end() != null ? ended.end() : otherwise; + this.round.witness().huntEnded(before, how); + } if (now != null) this.round.witness().hunted(now); } @@ -219,7 +230,8 @@ RouteProvider route() { void remove() { UUID before = this.hunting; this.hunting = null; - if (before != null) this.round.witness().huntEnded(before); + this.huntState = null; + if (before != null) this.round.witness().huntEnded(before, HuntEnd.REMOVED); 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 2339bc5a..81b81c1b 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 @@ -90,4 +90,17 @@ default void hunted(UUID survivor) { default void huntEnded(UUID survivor) { // Nobody listens by default. } + + /** + * A creek stopped hunting a survivor, and how. The creek reports this one; by default it passes + * on to {@link #huntEnded(UUID)}, so a witness that does not care about the reason is unchanged. + * A witness that decorates another one has to forward this method, not the other. + * + * @param survivor the survivor who is no longer hunted + * @param how why the hunt ended + * @since 2.15.0 + */ + default void huntEnded(UUID survivor, HuntEnd how) { + this.huntEnded(survivor); + } } diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/dread/HuntEnd.java b/game/src/main/java/net/onelitefeather/cygnus/creek/dread/HuntEnd.java new file mode 100644 index 00000000..48865222 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/dread/HuntEnd.java @@ -0,0 +1,22 @@ +package net.onelitefeather.cygnus.creek.dread; + +/** + * How a creek's hunt of a survivor ended, as told to {@link CreekWitness#huntEnded(java.util.UUID, HuntEnd)}. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.15.0 + */ +public enum HuntEnd { + + /** The creek got close enough with nothing in between. */ + CAUGHT, + /** The hunt ran out of time. */ + TIMEOUT, + /** The survivor was no longer among the hunted, because they died, left or were turned. */ + GONE, + /** The creek was sent away for good while it hunted. */ + SENT_AWAY, + /** The creek was taken out of the world, which is what the end of a round does. */ + REMOVED +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/HuntState.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/HuntState.java index 4ce2b953..64faa21a 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/state/HuntState.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/HuntState.java @@ -2,6 +2,8 @@ import net.onelitefeather.cygnus.common.config.CreekConfig; import net.onelitefeather.cygnus.creek.body.CreekBody; +import net.onelitefeather.cygnus.creek.dread.HuntEnd; +import org.jetbrains.annotations.Nullable; import java.util.List; import java.util.Optional; @@ -17,7 +19,7 @@ *

* * @author theEvilReaper - * @version 1.0.0 + * @version 1.1.0 * @since 2.15.0 */ public final class HuntState implements CreekState { @@ -35,6 +37,7 @@ public final class HuntState implements CreekState { private final long endsAt; private double bestDistance = Double.MAX_VALUE; private long progressSince; + private @Nullable HuntEnd end; /** * Sets up the hunt. @@ -76,6 +79,16 @@ public long endsAt() { return this.endsAt; } + /** + * Tells how the hunt ended. + * + * @return the reason, or {@code null} while the hunt goes on or when it was cut short from outside + * @since 2.15.0 + */ + public @Nullable HuntEnd end() { + return this.end; + } + @Override public void enter(CreekContext ctx) { CreekBody body = ctx.body(); @@ -88,7 +101,8 @@ public void enter(CreekContext ctx) { @Override public CreekState tick(CreekContext ctx) { Optional found = ctx.survivor(this.target); - if (found.isEmpty() || ctx.now() >= this.endsAt) return this.over(ctx); + if (found.isEmpty()) return this.over(ctx, HuntEnd.GONE); + if (ctx.now() >= this.endsAt) return this.over(ctx, HuntEnd.TIMEOUT); SurvivorView view = found.get(); CreekConfig config = ctx.config(); @@ -105,7 +119,7 @@ public CreekState tick(CreekContext ctx) { double distance = body.position().distance(view.position()); if (distance <= config.catchDistance() && view.inSight()) { ctx.actions().caught(this.target); - return this.over(ctx); + return this.over(ctx, HuntEnd.CAUGHT); } if (distance < this.bestDistance - PROGRESS) { @@ -124,7 +138,8 @@ public CreekState tick(CreekContext ctx) { /** * Ends the hunt and starts the survivor's breather. */ - private CreekState over(CreekContext ctx) { + private CreekState over(CreekContext ctx, HuntEnd how) { + this.end = how; ctx.hunts().ended(this.target, ctx.now()); return DoneState.INSTANCE; } 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 index 6b7d67b1..a86e72b6 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/tab/HuntedTabWitness.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/tab/HuntedTabWitness.java @@ -4,6 +4,7 @@ import net.kyori.adventure.text.format.NamedTextColor; import net.minestom.server.entity.Player; import net.onelitefeather.cygnus.creek.dread.CreekWitness; +import net.onelitefeather.cygnus.creek.dread.HuntEnd; import org.jetbrains.annotations.Nullable; import java.util.List; @@ -31,7 +32,7 @@ *

* * @author TheMeinerLP - * @version 1.1.0 + * @version 1.2.0 * @since 2.15.0 */ public final class HuntedTabWitness implements CreekWitness { @@ -92,9 +93,19 @@ public void hunted(UUID survivor) { }); } + @Override + public void huntEnded(UUID survivor, HuntEnd how) { + this.delegate.huntEnded(survivor, how); + this.unmark(survivor); + } + @Override public void huntEnded(UUID survivor) { this.delegate.huntEnded(survivor); + this.unmark(survivor); + } + + private void unmark(UUID survivor) { this.marks.computeIfPresent(survivor, (_, mark) -> { if (mark.hunts() > 1) return mark.fewer(); Player player = this.players.apply(survivor); diff --git a/game/src/main/java/net/onelitefeather/cygnus/telemetry/ActionTracer.java b/game/src/main/java/net/onelitefeather/cygnus/telemetry/ActionTracer.java index 5c0f9d00..66e0d6cd 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/telemetry/ActionTracer.java +++ b/game/src/main/java/net/onelitefeather/cygnus/telemetry/ActionTracer.java @@ -49,7 +49,7 @@ *

* * @author TheMeinerLP - * @version 1.0.0 + * @version 1.1.0 * @since 2.15.0 */ public final class ActionTracer { @@ -439,7 +439,7 @@ private long outMillis(Spawn spawn) { return Duration.between(spawn.at, this.clock.instant()).toMillis(); } - private static void position(TraceStep step, double x, double y, double z) { + static void position(TraceStep step, double x, double y, double z) { step.set(CygnusAttributes.POSITION_X, round(x)); step.set(CygnusAttributes.POSITION_Y, round(y)); step.set(CygnusAttributes.POSITION_Z, round(z)); diff --git a/game/src/main/java/net/onelitefeather/cygnus/telemetry/CygnusAttributes.java b/game/src/main/java/net/onelitefeather/cygnus/telemetry/CygnusAttributes.java index d1225bc1..910da2b0 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/telemetry/CygnusAttributes.java +++ b/game/src/main/java/net/onelitefeather/cygnus/telemetry/CygnusAttributes.java @@ -11,7 +11,7 @@ *

* * @author TheMeinerLP - * @version 1.0.0 + * @version 1.1.0 * @since 2.15.0 */ public final class CygnusAttributes { @@ -58,6 +58,8 @@ public final class CygnusAttributes { public static final String ACTION_CREEK_SELECTED = "cygnus.action.creek.selected"; public static final String ACTION_CREEK_STALK = "cygnus.action.creek.stalk"; public static final String ACTION_CREEK_CAUGHT = "cygnus.action.creek.caught"; + /** A creek hunting a survivor, from the start of the hunt to its end. */ + public static final String ACTION_CREEK_HUNT = "cygnus.action.creek.hunt"; // --- Events on the round span -------------------------------------------------------------- @@ -115,6 +117,8 @@ public final class CygnusAttributes { public static final AttributeKey SANITY_BAND = AttributeKey.stringKey("cygnus.sanity.band"); public static final AttributeKey SANITY_BAND_FROM = AttributeKey.stringKey("cygnus.sanity.band.from"); public static final AttributeKey SANITY_SOURCE = AttributeKey.stringKey("cygnus.sanity.source"); + public static final AttributeKey HUNT_OUTCOME = AttributeKey.stringKey("cygnus.creek.hunt.outcome"); + public static final AttributeKey HUNT_DURATION_MS = AttributeKey.longKey("cygnus.creek.hunt.duration_ms"); public static final AttributeKey LINK_KIND = AttributeKey.stringKey("cygnus.link.kind"); /** The link points at the round trace a player's cookie remembered. */ @@ -126,6 +130,19 @@ public final class CygnusAttributes { /** Link kind: from the shutdown to the round it cut short. */ public static final String LINK_ROUND = "round"; + // --- Values of creek.hunt.outcome ---------------------------------------------------------- + + /** The creek caught the survivor. */ + public static final String HUNT_CAUGHT = "caught"; + /** The creek was sent away while it hunted. */ + public static final String HUNT_ESCAPED = "escaped"; + /** The hunt ran out of time. */ + public static final String HUNT_TIMEOUT = "timeout"; + /** The survivor died or left. */ + public static final String HUNT_GONE = "gone"; + /** The round ended with the hunt still going. */ + public static final String HUNT_ROUND_END = "round_end"; + // --- Values of kick.completed_by ----------------------------------------------------------- /** The client confirmed it dropped the pack. */ diff --git a/game/src/main/java/net/onelitefeather/cygnus/telemetry/HuntMetrics.java b/game/src/main/java/net/onelitefeather/cygnus/telemetry/HuntMetrics.java new file mode 100644 index 00000000..eb2cd505 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/telemetry/HuntMetrics.java @@ -0,0 +1,125 @@ +package net.onelitefeather.cygnus.telemetry; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.DoubleHistogram; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.api.metrics.Meter; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * The metrics of the creek's hunts: how many there were and how long they lasted, by how they ended. + *

+ * The attributes are the outcome and the map only. A player is deliberately not one: the UUID is the + * span's job, on a metric it would make a series per player. The unit is milliseconds, like the tick + * metrics in {@link TickMetrics}. + *

+ * + * + * + * + * + * + * + *
The instruments
NameKindUnitAttributes
{@value #HUNT_DURATION}histogramms{@code cygnus.creek.hunt.outcome}, {@code cygnus.map}
{@value #HUNTS}counter{hunt}{@code cygnus.creek.hunt.outcome}, {@code cygnus.map}
+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.15.0 + */ +public final class HuntMetrics { + + /** Histogram of how long a hunt lasted. */ + public static final String HUNT_DURATION = "cygnus.creek.hunt.duration"; + /** Counter of the hunts that ended. */ + public static final String HUNTS = "cygnus.creek.hunts"; + + /** The value of the map attribute while no map is loaded. */ + public static final String NO_MAP = "none"; + + /** Boundaries in milliseconds: a hunt lasts from a few seconds up to its configured maximum. */ + public static final List BUCKETS_MS = List.of( + 500.0D, 1_000.0D, 2_500.0D, 5_000.0D, 7_500.0D, 10_000.0D, 15_000.0D, 20_000.0D, 30_000.0D, 45_000.0D, + 60_000.0D, 90_000.0D, 120_000.0D); + + /** Metrics that record nothing. */ + public static final HuntMetrics NONE = new HuntMetrics(null); + + private final @Nullable DoubleHistogram duration; + private final @Nullable LongCounter hunts; + private final Map> attributes = new ConcurrentHashMap<>(); + + private HuntMetrics(@Nullable Meter meter) { + if (meter == null) { + this.duration = null; + this.hunts = null; + return; + } + this.duration = meter.histogramBuilder(HUNT_DURATION) + .setDescription("How long a creek hunted a survivor, by how the hunt ended") + .setUnit("ms") + .setExplicitBucketBoundariesAdvice(BUCKETS_MS) + .build(); + this.hunts = meter.counterBuilder(HUNTS) + .setDescription("Hunts that ended, by how they ended") + .setUnit("{hunt}") + .build(); + } + + /** + * Builds the metrics from the global OpenTelemetry instance. Called once, by the composition root. + * + * @param version the version of the running service, reported with the instrumentation scope + * @return the metrics + */ + public static HuntMetrics fromGlobal(String version) { + return of(GlobalOpenTelemetry.get().getMeterProvider() + .meterBuilder(CygnusAttributes.INSTRUMENTATION_NAME) + .setInstrumentationVersion(version) + .build()); + } + + /** + * Wraps a meter, for tests and for callers that already hold one. + * + * @param meter the meter the instruments are created on + * @return the metrics + */ + public static HuntMetrics of(Meter meter) { + return new HuntMetrics(meter); + } + + /** + * Records a hunt that ended. + * + * @param outcome how it ended, one of the {@code HUNT_*} values of {@link CygnusAttributes} + * @param map the map being played, or {@code null} when none is loaded + * @param millis how long it lasted + */ + void record(String outcome, @Nullable String map, long millis) { + if (this.duration == null || this.hunts == null) { + return; + } + Attributes cached = attributes(outcome, map == null ? NO_MAP : map); + this.duration.record(millis, cached); + this.hunts.add(1L, cached); + } + + /** + * Returns the attributes of an outcome on a map. The same instance for the same pair, every time. + */ + Attributes attributes(String outcome, String map) { + Map byMap = this.attributes.computeIfAbsent(outcome, _ -> new ConcurrentHashMap<>()); + Attributes cached = byMap.get(map); + if (cached != null) { + return cached; + } + return byMap.computeIfAbsent(map, name -> Attributes.of( + CygnusAttributes.HUNT_OUTCOME, outcome, + CygnusAttributes.MAP, name)); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/telemetry/HuntTracer.java b/game/src/main/java/net/onelitefeather/cygnus/telemetry/HuntTracer.java new file mode 100644 index 00000000..5b66689c --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/telemetry/HuntTracer.java @@ -0,0 +1,158 @@ +package net.onelitefeather.cygnus.telemetry; + +import net.onelitefeather.cygnus.creek.dread.HuntEnd; +import org.jetbrains.annotations.Nullable; + +import java.time.Clock; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Measures how long a survivor is hunted: one span per hunt from its start to its end, and the + * duration as a metric. + *

+ * The creek reports a hunt by the survivor only, not by itself, so two creeks hunting the same + * survivor cannot be told apart here. Each start opens its own span and each end closes the + * oldest open one of that survivor - the count stays right, which creek a span belongs to + * may not. + *

+ *

+ * The outcome comes from {@link HuntEnd}: a catch is {@code caught}, a hunt out of time + * {@code timeout}, a survivor who left or died {@code gone}, a creek sent away {@code escaped}, and + * the end of the round {@code round_end}. Hunts still open when the round ends are closed as + * {@code round_end} before the round span ends. A start while no round runs has no span but is still + * counted, so the metric does not depend on tracing being on. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.15.0 + */ +public final class HuntTracer { + + private final RoundTracer rounds; + private final Clock clock; + private final Supplier<@Nullable String> mapName; + private final Function survivors; + private final HuntMetrics metrics; + private final Map> open = new HashMap<>(); + + /** + * Creates the tracer. + * + * @param rounds owns the round the spans are children of + * @param clock dates the start and the end of a hunt + * @param mapName supplies the name of the map being played, or {@code null} while none is loaded + * @param survivors resolves a survivor's UUID to who and where they are, or {@code null} if gone + * @param metrics where the duration of a hunt goes + */ + public HuntTracer(RoundTracer rounds, Clock clock, Supplier<@Nullable String> mapName, + Function survivors, HuntMetrics metrics) { + this.rounds = rounds; + this.clock = clock; + this.mapName = mapName; + this.survivors = survivors; + this.metrics = metrics; + rounds.onRoundEnding(this::endAll); + } + + /** + * A creek started to hunt a survivor. + * + * @param survivor the survivor + */ + public synchronized void started(UUID survivor) { + long now = this.clock.millis(); + String map = this.mapName.get(); + TraceStep step = null; + try { + step = this.rounds.actionSpan(CygnusAttributes.ACTION_CREEK_HUNT); + if (step != null) { + ActionTracer.Actor actor = this.survivors.apply(survivor); + step.set(CygnusAttributes.PLAYER_UUID, survivor.toString()); + if (actor != null) { + step.set(CygnusAttributes.PLAYER_ROLE, actor.role()); + ActionTracer.position(step, actor.x(), actor.y(), actor.z()); + } + if (map != null) { + step.set(CygnusAttributes.MAP, map); + } + } + } catch (RuntimeException exception) { + // observing must not change what happens; the hunt is still counted + if (step != null) { + step.fail(exception); + } + } + this.open.computeIfAbsent(survivor, _ -> new ArrayDeque<>()).addLast(new Hunt(step, now, map)); + } + + /** + * A creek stopped hunting a survivor. + * + * @param survivor the survivor + * @param how how the hunt ended + */ + public synchronized void ended(UUID survivor, HuntEnd how) { + ArrayDeque hunts = this.open.get(survivor); + if (hunts == null) { + return; + } + Hunt hunt = hunts.pollFirst(); + if (hunts.isEmpty()) { + this.open.remove(survivor); + } + if (hunt != null) { + finish(hunt, outcome(how)); + } + } + + /** + * Closes every hunt still open, because the round is ending. + */ + private synchronized void endAll() { + List hunts = new ArrayList<>(); + this.open.values().forEach(hunts::addAll); + this.open.clear(); + hunts.forEach(hunt -> finish(hunt, CygnusAttributes.HUNT_ROUND_END)); + } + + private void finish(Hunt hunt, String outcome) { + long millis = Math.max(0L, this.clock.millis() - hunt.startedAt); + TraceStep step = hunt.step; + try { + if (step != null) { + step.set(CygnusAttributes.HUNT_OUTCOME, outcome); + step.set(CygnusAttributes.HUNT_DURATION_MS, millis); + } + this.metrics.record(outcome, hunt.map, millis); + } catch (RuntimeException exception) { + if (step != null) { + step.fail(exception); + } + } finally { + if (step != null) { + step.close(); + } + } + } + + static String outcome(HuntEnd how) { + return switch (how) { + case CAUGHT -> CygnusAttributes.HUNT_CAUGHT; + case TIMEOUT -> CygnusAttributes.HUNT_TIMEOUT; + case GONE -> CygnusAttributes.HUNT_GONE; + case SENT_AWAY -> CygnusAttributes.HUNT_ESCAPED; + case REMOVED -> CygnusAttributes.HUNT_ROUND_END; + }; + } + + private record Hunt(@Nullable TraceStep step, long startedAt, @Nullable String map) { + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/telemetry/RoundTracer.java b/game/src/main/java/net/onelitefeather/cygnus/telemetry/RoundTracer.java index 7cdab0c1..427c6fc4 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/telemetry/RoundTracer.java +++ b/game/src/main/java/net/onelitefeather/cygnus/telemetry/RoundTracer.java @@ -36,7 +36,7 @@ *

* * @author TheMeinerLP - * @version 1.1.0 + * @version 1.2.0 * @since 2.15.0 */ public final class RoundTracer { @@ -64,6 +64,7 @@ public final class RoundTracer { private final Set linked = new HashSet<>(); private int droppedLinks; private final List boundaryListeners = new CopyOnWriteArrayList<>(); + private final List endingListeners = new CopyOnWriteArrayList<>(); /** * Creates the tracer. @@ -190,6 +191,12 @@ synchronized void phaseFailed(Throwable throwable) { * Ends the round span after its last phase. */ synchronized void roundEnded() { + // Before the spans close, so whatever is still open ends below a parent that is still running. + if (this.round != null) { + for (Runnable listener : this.endingListeners) { + listener.run(); + } + } endPhase(); TraceStep current = this.round; this.round = null; @@ -212,6 +219,15 @@ void onBoundary(Runnable listener) { this.boundaryListeners.add(listener); } + /** + * Registers something that has to finish what it holds open before the round and its phase end. + * + * @param listener called when a running round ends, before its spans close; must be quick + */ + void onRoundEnding(Runnable listener) { + this.endingListeners.add(listener); + } + private void notifyBoundary() { for (Runnable listener : this.boundaryListeners) { listener.run(); 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 8f5e6778..3287169d 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/telemetry/TracingCreekWitness.java +++ b/game/src/main/java/net/onelitefeather/cygnus/telemetry/TracingCreekWitness.java @@ -1,6 +1,7 @@ package net.onelitefeather.cygnus.telemetry; import net.onelitefeather.cygnus.creek.dread.CreekWitness; +import net.onelitefeather.cygnus.creek.dread.HuntEnd; import org.jetbrains.annotations.Nullable; import java.util.Set; @@ -21,7 +22,7 @@ *

* * @author TheMeinerLP - * @version 1.1.0 + * @version 1.2.0 * @since 2.15.0 */ public final class TracingCreekWitness implements CreekWitness { @@ -30,6 +31,7 @@ public final class TracingCreekWitness implements CreekWitness { private final ActionTracer actions; private final Function survivors; private final Set stalking = ConcurrentHashMap.newKeySet(); + private final @Nullable HuntTracer hunts; /** * Creates the decorator. @@ -41,6 +43,21 @@ public final class TracingCreekWitness implements CreekWitness { */ public TracingCreekWitness(CreekWitness delegate, ActionTracer actions, Function survivors) { + this(delegate, actions, survivors, null); + } + + /** + * Creates the decorator so that hunts are measured too. + * + * @param delegate the witness that still has to hear everything + * @param actions records the actions + * @param survivors resolves a survivor's UUID to who and where they are, or {@code null} if gone + * @param hunts measures the hunts, or {@code null} to leave them alone + * @since 2.15.0 + */ + public TracingCreekWitness(CreekWitness delegate, ActionTracer actions, + Function survivors, @Nullable HuntTracer hunts) { + this.hunts = hunts; this.delegate = delegate; this.actions = actions; this.survivors = survivors; @@ -78,6 +95,9 @@ public void selected(UUID survivor) { @Override public void hunted(UUID survivor) { + if (this.hunts != null) { + this.hunts.started(survivor); + } this.delegate.hunted(survivor); } @@ -86,6 +106,14 @@ public void huntEnded(UUID survivor) { this.delegate.huntEnded(survivor); } + @Override + public void huntEnded(UUID survivor, HuntEnd how) { + if (this.hunts != null) { + this.hunts.ended(survivor, how); + } + this.delegate.huntEnded(survivor, how); + } + /** * 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 66bc9427..79419181 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java @@ -12,6 +12,7 @@ import net.onelitefeather.cygnus.creek.consequence.PatrolHelper; import net.onelitefeather.cygnus.creek.consequence.StalkSounds; import net.onelitefeather.cygnus.creek.dread.CreekWitness; +import net.onelitefeather.cygnus.creek.dread.HuntEnd; import net.onelitefeather.cygnus.creek.state.Contexts; import net.onelitefeather.cygnus.creek.state.DoneState; import net.onelitefeather.cygnus.creek.state.CreekContext; @@ -66,6 +67,13 @@ private static final class RecordingWitness implements CreekWitness { private final List selections = new ArrayList<>(); private final List hunts = new ArrayList<>(); private final List huntEnds = new ArrayList<>(); + private final List huntReasons = new ArrayList<>(); + + @Override + public void huntEnded(UUID survivor, HuntEnd how) { + this.huntReasons.add(how); + this.huntEnded(survivor); + } @Override public void hunted(UUID survivor) { @@ -381,6 +389,7 @@ void catchEndsTheHunt(Env env) { assertEquals(List.of(survivor.getUuid()), witness.hunts); assertEquals(List.of(survivor.getUuid()), witness.huntEnds); + assertEquals(List.of(HuntEnd.CAUGHT), witness.huntReasons); } @Test @@ -396,6 +405,7 @@ void timeoutEndsTheHunt(Env env) { creek.tick(survivors(survivor), 600L); assertEquals(List.of(survivor.getUuid()), witness.huntEnds); + assertEquals(List.of(HuntEnd.TIMEOUT), witness.huntReasons); } @Test @@ -412,6 +422,7 @@ void goneSurvivorEndsTheHunt(Env env) { creek.tick(survivors(other), 100L); assertEquals(List.of(target.getUuid()), witness.huntEnds); + assertEquals(List.of(HuntEnd.GONE), witness.huntReasons); } @Test @@ -428,6 +439,7 @@ void removingEndsTheHunt(Env env) { creek.remove(); assertEquals(List.of(survivor.getUuid()), witness.huntEnds); + assertEquals(List.of(HuntEnd.REMOVED), witness.huntReasons); } @Test @@ -443,6 +455,7 @@ void vanishingEndsTheHunt(Env env) { creek.vanishForGood(survivors(survivor), 100L); assertEquals(List.of(survivor.getUuid()), witness.huntEnds); + assertEquals(List.of(HuntEnd.SENT_AWAY), witness.huntReasons); } @Test diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/state/HuntStateEndTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/state/HuntStateEndTest.java new file mode 100644 index 00000000..017dc3c8 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/HuntStateEndTest.java @@ -0,0 +1,70 @@ +package net.onelitefeather.cygnus.creek.state; + +import net.minestom.server.coordinate.Pos; +import net.onelitefeather.cygnus.creek.dread.HuntEnd; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Covers how a hunt says why it ended, which is what the hunt outcome is made from. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.15.0 + */ +class HuntStateEndTest { + + private static final UUID TARGET = UUID.randomUUID(); + private static final Pos TARGET_POS = new Pos(0, 40, 0, 180, 0); + + private static CreekContext at(long now, RecordingBody body, UUID who) { + return Contexts.context(now, body, Contexts.route(), new ArrayList<>(), + new SurvivorView(who, TARGET_POS, 0.7D, false)); + } + + @Test + @DisplayName("A running hunt has no end yet") + void runningHuntHasNoEnd() { + HuntState hunt = new HuntState(TARGET, 30_000L); + + hunt.tick(at(0L, new RecordingBody(new Pos(0, 40, 10)), TARGET)); + + assertNull(hunt.end()); + } + + @Test + @DisplayName("A catch ends the hunt as caught") + void catchIsCaught() { + HuntState hunt = new HuntState(TARGET, 30_000L); + + hunt.tick(at(0L, new RecordingBody(new Pos(0, 40, 1)), TARGET)); + + assertEquals(HuntEnd.CAUGHT, hunt.end()); + } + + @Test + @DisplayName("Running out of time ends the hunt as a timeout") + void timeIsUp() { + HuntState hunt = new HuntState(TARGET, 30_000L); + + hunt.tick(at(30_000L, new RecordingBody(new Pos(0, 40, 10)), TARGET)); + + assertEquals(HuntEnd.TIMEOUT, hunt.end()); + } + + @Test + @DisplayName("A target that is no longer among the survivors ends the hunt as gone") + void targetIsGone() { + HuntState hunt = new HuntState(TARGET, 30_000L); + + hunt.tick(at(0L, new RecordingBody(new Pos(0, 40, 10)), UUID.randomUUID())); + + assertEquals(HuntEnd.GONE, hunt.end()); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/telemetry/HuntTracerTest.java b/game/src/test/java/net/onelitefeather/cygnus/telemetry/HuntTracerTest.java new file mode 100644 index 00000000..356e5856 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/telemetry/HuntTracerTest.java @@ -0,0 +1,237 @@ +package net.onelitefeather.cygnus.telemetry; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.data.HistogramPointData; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import io.opentelemetry.sdk.trace.data.SpanData; +import net.onelitefeather.cygnus.creek.dread.HuntEnd; +import net.onelitefeather.cygnus.telemetry.ActionTracer.Actor; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Covers the hunt span and the hunt metrics. Time is a clock the test moves by hand, the SDK is a + * private in-memory one, nothing waits. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.15.0 + */ +class HuntTracerTest { + + private static final UUID SURVIVOR = UUID.fromString("00000000-0000-0000-0000-0000000000a1"); + + private TestTelemetry telemetry; + private InMemoryMetricReader reader; + private SdkMeterProvider meters; + private RoundTracer rounds; + private HuntMetrics metrics; + private HuntTracer hunts; + private AtomicReference now; + + @BeforeEach + void setUp() { + this.telemetry = new TestTelemetry(); + this.reader = InMemoryMetricReader.create(); + this.meters = SdkMeterProvider.builder().registerMetricReader(this.reader).build(); + this.metrics = HuntMetrics.of(this.meters.get("test")); + this.rounds = new RoundTracer(this.telemetry.tracing(), () -> "round-1"); + this.now = new AtomicReference<>(Instant.parse("2026-01-01T00:00:00Z")); + Clock clock = new Clock() { + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return now.get(); + } + }; + this.hunts = new HuntTracer(this.rounds, clock, () -> "Cabin", + id -> new Actor(id, "survivor", 1.04D, 40.0D, -3.0D), this.metrics); + } + + @AfterEach + void tearDown() { + this.meters.close(); + this.telemetry.close(); + } + + private void pass(long seconds) { + this.now.updateAndGet(instant -> instant.plusSeconds(seconds)); + } + + private List huntSpans() { + return this.telemetry.spans().stream() + .filter(span -> span.getName().equals(CygnusAttributes.ACTION_CREEK_HUNT)).toList(); + } + + private MetricData metric(String name) { + return this.reader.collectAllMetrics().stream().filter(data -> data.getName().equals(name)) + .findFirst().orElseThrow(() -> new AssertionError("no metric '" + name + "'")); + } + + @ParameterizedTest(name = "{0} ends the hunt as {1}") + @CsvSource({"CAUGHT,caught", "TIMEOUT,timeout", "GONE,gone", "SENT_AWAY,escaped", "REMOVED,round_end"}) + @DisplayName("Every way a hunt can end has its outcome") + void outcomePerEndPath(HuntEnd how, String outcome) { + this.rounds.roundStarted(); + this.hunts.started(SURVIVOR); + pass(12); + + this.hunts.ended(SURVIVOR, how); + + SpanData span = huntSpans().getFirst(); + assertEquals(outcome, span.getAttributes().get(CygnusAttributes.HUNT_OUTCOME)); + assertEquals(12_000L, span.getAttributes().get(CygnusAttributes.HUNT_DURATION_MS)); + } + + @Test + @DisplayName("A hunt span says who, where and on which map, and hangs below the phase") + void spanAttributes() { + this.rounds.roundStarted(); + this.rounds.phaseStarted("GamePhase"); + this.hunts.started(SURVIVOR); + + this.hunts.ended(SURVIVOR, HuntEnd.CAUGHT); + this.rounds.roundEnded(); + + SpanData span = huntSpans().getFirst(); + assertEquals(SURVIVOR.toString(), span.getAttributes().get(CygnusAttributes.PLAYER_UUID)); + assertEquals("survivor", span.getAttributes().get(CygnusAttributes.PLAYER_ROLE)); + assertEquals(1.0D, span.getAttributes().get(CygnusAttributes.POSITION_X)); + assertEquals(40.0D, span.getAttributes().get(CygnusAttributes.POSITION_Y)); + assertEquals("Cabin", span.getAttributes().get(CygnusAttributes.MAP)); + assertEquals(this.telemetry.span("cygnus.phase.gamephase").getSpanId(), span.getParentSpanId()); + } + + @Test + @DisplayName("The hunt duration is recorded with the outcome and the map") + void histogramHasOutcomeAndMap() { + this.rounds.roundStarted(); + this.hunts.started(SURVIVOR); + pass(8); + this.hunts.ended(SURVIVOR, HuntEnd.TIMEOUT); + + MetricData data = metric(HuntMetrics.HUNT_DURATION); + assertEquals("ms", data.getUnit()); + HistogramPointData point = data.getHistogramData().getPoints().iterator().next(); + assertEquals(1, point.getCount()); + assertEquals(8_000.0D, point.getSum(), 1e-9); + assertEquals(Attributes.of(CygnusAttributes.HUNT_OUTCOME, "timeout", CygnusAttributes.MAP, "Cabin"), + point.getAttributes()); + assertEquals(1L, metric(HuntMetrics.HUNTS).getLongSumData().getPoints().iterator().next().getValue()); + } + + @Test + @DisplayName("No metric carries a player") + void metricsHaveNoPlayer() { + this.rounds.roundStarted(); + this.hunts.started(SURVIVOR); + this.hunts.ended(SURVIVOR, HuntEnd.CAUGHT); + + for (String name : List.of(HuntMetrics.HUNT_DURATION, HuntMetrics.HUNTS)) { + metric(name).getData().getPoints().forEach(point -> { + assertEquals(2, point.getAttributes().size(), "outcome and map only"); + assertNull(point.getAttributes().get(CygnusAttributes.PLAYER_UUID)); + }); + } + } + + @Test + @DisplayName("The ending of a round closes the hunts still open as round_end, below a phase that still runs") + void roundEndClosesOpenHunts() { + this.rounds.roundStarted(); + this.rounds.phaseStarted("GamePhase"); + this.hunts.started(SURVIVOR); + this.hunts.started(UUID.randomUUID()); + pass(5); + + this.rounds.roundEnded(); + + List spans = huntSpans(); + assertEquals(2, spans.size()); + SpanData phase = this.telemetry.span("cygnus.phase.gamephase"); + spans.forEach(span -> { + assertEquals("round_end", span.getAttributes().get(CygnusAttributes.HUNT_OUTCOME)); + assertEquals(5_000L, span.getAttributes().get(CygnusAttributes.HUNT_DURATION_MS)); + assertTrue(span.getEndEpochNanos() <= phase.getEndEpochNanos(), "ends before its parent"); + }); + assertEquals(2L, metric(HuntMetrics.HUNTS).getLongSumData().getPoints().iterator().next().getValue()); + } + + @Test + @DisplayName("Two creeks hunting one survivor are two spans, closed one by one") + void twoHuntsOfOneSurvivor() { + this.rounds.roundStarted(); + this.hunts.started(SURVIVOR); + pass(2); + this.hunts.started(SURVIVOR); + pass(3); + + this.hunts.ended(SURVIVOR, HuntEnd.CAUGHT); + assertEquals(1, huntSpans().size()); + this.hunts.ended(SURVIVOR, HuntEnd.TIMEOUT); + + assertEquals(2, huntSpans().size()); + } + + @Test + @DisplayName("An end without a start is ignored, and a hunt is counted even when no round runs") + void endWithoutStartAndHuntWithoutRound() { + this.hunts.ended(SURVIVOR, HuntEnd.CAUGHT); + assertTrue(this.reader.collectAllMetrics().isEmpty()); + + this.hunts.started(SURVIVOR); + pass(1); + this.hunts.ended(SURVIVOR, HuntEnd.GONE); + + assertTrue(huntSpans().isEmpty(), "no round, no trace to belong to"); + assertEquals(1L, metric(HuntMetrics.HUNTS).getLongSumData().getPoints().iterator().next().getValue()); + } + + @Test + @DisplayName("The attributes of an outcome on a map are built once and reused") + void attributesAreReused() { + assertSame(this.metrics.attributes("caught", "Cabin"), this.metrics.attributes("caught", "Cabin")); + } + + @Test + @DisplayName("Metrics that record nothing leave the span working") + void noneMetrics() { + HuntTracer plain = new HuntTracer(this.rounds, Clock.fixed(Instant.EPOCH, ZoneOffset.UTC), () -> null, + id -> null, HuntMetrics.NONE); + this.rounds.roundStarted(); + + plain.started(SURVIVOR); + plain.ended(SURVIVOR, HuntEnd.CAUGHT); + + assertEquals(1, huntSpans().size()); + assertNull(huntSpans().getFirst().getAttributes().get(CygnusAttributes.MAP)); + } +}