From 8a29e6d7600816b4d936af685df055d193cdcd71 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:29:08 +0200 Subject: [PATCH 1/3] Read the signal on a log scale in 3D so the last stretch keeps moving The bar and the clicks scaled linearly over 2,500+ blocks, so the last few hundred blocks all read as full and players could not tell where to dig. Both now fill on a log scale of the 3D distance, reaching full at pinpoint-range, so each halving of the distance moves them the same amount and standing high above the cache no longer reads as there. Defaults: pinpoint-range 12 -> 20 so the close reading reaches the surface on slopes, close-range-threshold 200 -> 60 so particles turn white only near the end. Co-Authored-By: Claude Opus 5.5 --- .../config/GeigerConfiguration.java | 8 ++--- .../handlers/GeigerClickPlayer.java | 12 ++++---- .../geigercounters/handlers/SignalBar.java | 12 ++++---- .../managers/GeigerManager.java | 7 +++-- .../geigercounters/utils/Closeness.java | 24 +++++++++++++++ src/main/resources/config.yml | 25 ++++++++++------ .../config/GeigerConfigurationTest.java | 10 +++---- .../handlers/GeigerEffectsTest.java | 20 +++++++++++++ .../handlers/SignalBarTest.java | 17 ++++++++--- .../managers/GeigerManagerTest.java | 23 ++++++++------- .../geigercounters/utils/ClosenessTest.java | 29 +++++++++++++++++++ 11 files changed, 142 insertions(+), 45 deletions(-) create mode 100644 src/main/java/net/tfminecraft/geigercounters/utils/Closeness.java create mode 100644 src/test/java/net/tfminecraft/geigercounters/utils/ClosenessTest.java diff --git a/src/main/java/net/tfminecraft/geigercounters/config/GeigerConfiguration.java b/src/main/java/net/tfminecraft/geigercounters/config/GeigerConfiguration.java index 4b4287f..163aac3 100644 --- a/src/main/java/net/tfminecraft/geigercounters/config/GeigerConfiguration.java +++ b/src/main/java/net/tfminecraft/geigercounters/config/GeigerConfiguration.java @@ -215,14 +215,14 @@ private void loadSpawnFilters() { private void loadDetectionSettings() { maxDetectionDistance = plugin.getConfig().getDouble("detection.max-detection-distance", 2500.0); - pinpointRange = plugin.getConfig().getDouble("detection.pinpoint-range", 12.0); - closeRangeThreshold = plugin.getConfig().getDouble("detection.close-range-threshold", 200.0); + pinpointRange = plugin.getConfig().getDouble("detection.pinpoint-range", 20.0); + closeRangeThreshold = plugin.getConfig().getDouble("detection.close-range-threshold", 60.0); threeRingsDistance = plugin.getConfig().getDouble("detection.ring-thresholds.three-rings", 100.0); twoRingsDistance = plugin.getConfig().getDouble("detection.ring-thresholds.two-rings", 300.0); if (pinpointRange <= 0.0) { - plugin.getLogger().warning("detection.pinpoint-range must be greater than 0 - falling back to 12."); - pinpointRange = 12.0; + plugin.getLogger().warning("detection.pinpoint-range must be greater than 0 - falling back to 20."); + pinpointRange = 20.0; } } diff --git a/src/main/java/net/tfminecraft/geigercounters/handlers/GeigerClickPlayer.java b/src/main/java/net/tfminecraft/geigercounters/handlers/GeigerClickPlayer.java index 9c1955a..8691236 100644 --- a/src/main/java/net/tfminecraft/geigercounters/handlers/GeigerClickPlayer.java +++ b/src/main/java/net/tfminecraft/geigercounters/handlers/GeigerClickPlayer.java @@ -4,6 +4,7 @@ import org.bukkit.SoundCategory; import org.bukkit.entity.Player; import net.tfminecraft.geigercounters.config.GeigerConfiguration; +import net.tfminecraft.geigercounters.utils.Closeness; import java.util.HashMap; import java.util.Iterator; @@ -45,8 +46,8 @@ public GeigerClickPlayer(GeigerConfiguration config) { } // ==================================== - // Record how fast this player should be clicking - // Called by the periodic player check, not every tick + // Record how fast this player should be clicking for a cache + // blocks away in 3D. Called by the periodic player check, not every tick // ==================================== public void updateRate(Player player, double distance) { setRate(player, calculateClicksPerTick(distance)); @@ -127,7 +128,8 @@ public void clear() { // ==================================== // Map distance to clicks per tick. // - // Full rate at the source, falling to the minimum at max detection range. + // Full rate at pinpoint range, falling to the minimum at max detection + // range on a log scale, so the rate still climbs in the last stretch. // The curve exponent biases the fast clicking toward close range, so the // "getting hot" feeling lands where the player is actually hunting. // ==================================== @@ -137,8 +139,8 @@ private double calculateClicksPerTick(double distance) { return 0.0; } - double progress = Math.min(1.0, Math.max(0.0, distance / maxDistance)); - double closeness = Math.pow(1.0 - progress, config.getSoundCurve()); + double closeness = Math.pow( + Closeness.logarithmic(distance, config.getPinpointRange(), maxDistance), config.getSoundCurve()); double minRate = config.getSoundMinRate(); double maxRate = config.getSoundMaxRate(); diff --git a/src/main/java/net/tfminecraft/geigercounters/handlers/SignalBar.java b/src/main/java/net/tfminecraft/geigercounters/handlers/SignalBar.java index 0356b69..24f348d 100644 --- a/src/main/java/net/tfminecraft/geigercounters/handlers/SignalBar.java +++ b/src/main/java/net/tfminecraft/geigercounters/handlers/SignalBar.java @@ -7,6 +7,7 @@ import org.bukkit.Location; import org.bukkit.entity.Player; import net.tfminecraft.geigercounters.config.GeigerConfiguration; +import net.tfminecraft.geigercounters.utils.Closeness; import java.util.HashMap; import java.util.Iterator; @@ -18,8 +19,10 @@ // Boss bar shown while a player holds a detector. // // It fills as the player closes in and peaks when they face their source, so -// sweeping round on the spot shows which way to walk. Within pinpoint range -// it retitles and shows closeness to the cache alone, to find where to dig. +// sweeping round on the spot shows which way to walk. It fills on a log +// scale, reaching full at pinpoint range, so it keeps moving on the way in. +// Within pinpoint range it retitles and shows closeness to the cache alone, +// to find where to dig. // MMOCore owns the action bar, which is why this sits at the top of the screen. // ==================================== public class SignalBar { @@ -43,7 +46,7 @@ public SignalBar(GeigerConfiguration config) { } // ==================================== - // Show or refresh the player's bar for a source blocks away + // Show or refresh the player's bar for a cache blocks away in 3D // ==================================== public void update(Player player, Location source, double distance) { show(player, signal(player.getLocation(), source, distance), TITLE); @@ -121,8 +124,7 @@ private static void hideFrom(UUID playerId, BossBar bar) { // Facing away still shows (1 - facing-weight) of the closeness. // ==================================== double signal(Location from, Location source, double distance) { - double range = config.getBarRange(); - double closeness = 1.0 - Math.min(distance, range) / range; + double closeness = Closeness.logarithmic(distance, config.getPinpointRange(), config.getBarRange()); double weight = config.getBarFacingWeight(); return closeness * ((1.0 - weight) + weight * facing(from, source)); } diff --git a/src/main/java/net/tfminecraft/geigercounters/managers/GeigerManager.java b/src/main/java/net/tfminecraft/geigercounters/managers/GeigerManager.java index a897e84..67534a6 100644 --- a/src/main/java/net/tfminecraft/geigercounters/managers/GeigerManager.java +++ b/src/main/java/net/tfminecraft/geigercounters/managers/GeigerManager.java @@ -250,14 +250,15 @@ private boolean handlePlayerWithGeiger(Player player, UUID hunter, Set rev particleRenderer.showParticleEffect(player, distance); - // Depth counts once the hunter is close enough to start digging + // The readings count depth, so standing high above the cache never + // reads as full; within pinpoint range they switch to the close reading double reach = player.getLocation().distance(source); if (reach <= configuration.getPinpointRange()) { clickPlayer.updatePinpoint(player, reach); signalBar.pinpoint(player, reach); } else { - clickPlayer.updateRate(player, distance); - signalBar.update(player, source, distance); + clickPlayer.updateRate(player, reach); + signalBar.update(player, source, reach); } if (reach <= BuriedChest.REVEAL_DISTANCE) { diff --git a/src/main/java/net/tfminecraft/geigercounters/utils/Closeness.java b/src/main/java/net/tfminecraft/geigercounters/utils/Closeness.java new file mode 100644 index 0000000..9993d1b --- /dev/null +++ b/src/main/java/net/tfminecraft/geigercounters/utils/Closeness.java @@ -0,0 +1,24 @@ +package net.tfminecraft.geigercounters.utils; + +// ==================================== +// How close a reading is, from 0 at to 1 at and closer. +// +// The scale is logarithmic: every halving of the distance adds the same +// amount. On a straight line a 2,500-block range leaves the last few hundred +// blocks reading as all but full, so nothing changes where players need it. +// ==================================== +public final class Closeness { + + private Closeness() { + } + + public static double logarithmic(double distance, double near, double far) { + if (distance <= near) { + return 1.0; + } + if (distance >= far) { + return 0.0; + } + return 1.0 - Math.log(distance / near) / Math.log(far / near); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index cdcb411..3b8341e 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -85,11 +85,15 @@ detection: # Within this many blocks of the cache (measured in 3D, depth included) # the detector switches to a close reading: the bar and clicks start over - # from low and peak on the cache itself, to show where to dig - pinpoint-range: 12.0 - - # Distance threshold for close/far range colors (blocks) - close-range-threshold: 200.0 + # from low and peak on the cache itself, to show where to dig. + # Caches lie up to burial.max-depth under the ground, so keep this well + # above that or the close reading barely reaches the surface on slopes. + pinpoint-range: 20.0 + + # Distance threshold for close/far range colors (blocks). The particles + # turn white over this last stretch, so a large value makes them look + # white long before the close reading starts. + close-range-threshold: 60.0 # Ring count thresholds - number of particle rings based on distance ring-thresholds: @@ -100,8 +104,10 @@ detection: # ==================================== # Signal bar # A boss bar shown while holding a detector. It fills as the player gets -# closer and peaks when they face their source. Within pinpoint-range it -# shows closeness to the cache alone. +# closer and peaks when they face their source. Distance is measured in 3D +# on a log scale: each halving of the distance fills the same amount, and the +# bar is full at pinpoint-range. Within pinpoint-range it shows closeness to +# the cache alone. # ==================================== bar: enabled: true @@ -135,13 +141,14 @@ sound: pitch: 1.7 pitch-variance: 0.15 - # Clicks per second at max-detection-distance and at the source itself. + # Clicks per second at max-detection-distance and at pinpoint-range. + # Like the bar, the rate climbs on a log scale of the 3D distance. # 20 is the ceiling - one click per tick min-rate: 0.5 max-rate: 18.0 # Higher values keep the clicking slow until the player is close, - # then ramp hard. 1.0 is a straight line + # then ramp hard. 1.0 follows the log scale as it is curve: 2.0 diff --git a/src/test/java/net/tfminecraft/geigercounters/config/GeigerConfigurationTest.java b/src/test/java/net/tfminecraft/geigercounters/config/GeigerConfigurationTest.java index a171f39..d935a91 100644 --- a/src/test/java/net/tfminecraft/geigercounters/config/GeigerConfigurationTest.java +++ b/src/test/java/net/tfminecraft/geigercounters/config/GeigerConfigurationTest.java @@ -53,7 +53,7 @@ void defaultsProvideUsableDetectionSoundLimitsColorsAndSpawnFilters() { assertSame(world, configuration.getWorld()); assertEquals(GeigerConfiguration.HuntMode.PLAYER, configuration.getHuntMode()); assertEquals(2500, configuration.getMaxDetectionDistance()); - assertEquals(12, configuration.getPinpointRange()); + assertEquals(20, configuration.getPinpointRange()); assertEquals(300, configuration.getMinSourceDistance()); assertEquals(600, configuration.getMaxSourceDistance()); assertEquals(3, configuration.getMinBurialDepth()); @@ -62,7 +62,7 @@ void defaultsProvideUsableDetectionSoundLimitsColorsAndSpawnFilters() { assertTrue(configuration.isBarEnabled()); assertEquals(750, configuration.getBarRange()); assertEquals(0.6, configuration.getBarFacingWeight()); - assertEquals(200, configuration.getCloseRangeThreshold()); + assertEquals(60, configuration.getCloseRangeThreshold()); assertEquals(100, configuration.getThreeRingsDistance()); assertEquals(300, configuration.getTwoRingsDistance()); assertTrue(configuration.isSoundEnabled()); @@ -148,7 +148,7 @@ void sourceDistancesAndBarSettingsAreKeptConsistent() { load(); assertEquals(1, configuration.getMinBurialDepth()); assertEquals(1, configuration.getMaxBurialDepth()); - assertEquals(12, configuration.getPinpointRange()); + assertEquals(20, configuration.getPinpointRange()); assertEquals(0, configuration.getMinSourceDistance()); assertEquals(0, configuration.getMaxSourceDistance()); assertEquals(0, configuration.getRerollDistance()); @@ -162,11 +162,11 @@ void sourceDistancesAndBarSettingsAreKeptConsistent() { yaml.set("bar.facing-weight", -1); yaml.set("source.burial.min-depth", 2); yaml.set("source.burial.max-depth", 8); - yaml.set("detection.pinpoint-range", 20); + yaml.set("detection.pinpoint-range", 30); load(); assertEquals(2, configuration.getMinBurialDepth()); assertEquals(8, configuration.getMaxBurialDepth()); - assertEquals(20, configuration.getPinpointRange()); + assertEquals(30, configuration.getPinpointRange()); assertEquals(100, configuration.getMinSourceDistance()); assertEquals(300, configuration.getMaxSourceDistance()); assertEquals(600, configuration.getRerollDistance()); diff --git a/src/test/java/net/tfminecraft/geigercounters/handlers/GeigerEffectsTest.java b/src/test/java/net/tfminecraft/geigercounters/handlers/GeigerEffectsTest.java index c0451dd..abf3897 100644 --- a/src/test/java/net/tfminecraft/geigercounters/handlers/GeigerEffectsTest.java +++ b/src/test/java/net/tfminecraft/geigercounters/handlers/GeigerEffectsTest.java @@ -29,6 +29,7 @@ void setUp() { when(player.isOnline()).thenReturn(true); when(player.getLocation()).thenAnswer(invocation -> new Location(world, 10, 64, 20)); when(config.getMaxDetectionDistance()).thenReturn(1000.0); + when(config.getPinpointRange()).thenReturn(10.0); when(config.getCloseRangeThreshold()).thenReturn(50.0); when(config.getThreeRingsDistance()).thenReturn(100.0); when(config.getTwoRingsDistance()).thenReturn(300.0); @@ -127,6 +128,25 @@ void soundRateFallsWithDistanceAndStopsOutsideDetectionRange() throws Exception } } + @Test + void soundRateClimbsOnALogScaleAndPeaksAtPinpointRange() throws Exception { + // Clicks per tick: 1 at pinpoint range; at 100 blocks closeness 0.5, + // squared by the curve, gives (1 + 19 * 0.25) / 20 = 0.2875 + double[][] cases = {{10, 0.999, 1}, {100, 0.28, 1}, {100, 0.29, 0}}; + for (double[] reading : cases) { + GeigerClickPlayer clicker = clicker(reading[1]); + clearInvocations(player); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(player); + clicker.updateRate(player, reading[0]); + clicker.tick(); + } + verify(player, times((int) reading[2])) + .playSound( + any(Location.class), anyString(), any(SoundCategory.class), anyFloat(), anyFloat()); + } + } + @Test void pinpointRateRestartsAtMinRateOnTheEdgeAndPeaksOnTheCache() throws Exception { when(config.getPinpointRange()).thenReturn(10.0); diff --git a/src/test/java/net/tfminecraft/geigercounters/handlers/SignalBarTest.java b/src/test/java/net/tfminecraft/geigercounters/handlers/SignalBarTest.java index 0752b1b..5f79728 100644 --- a/src/test/java/net/tfminecraft/geigercounters/handlers/SignalBarTest.java +++ b/src/test/java/net/tfminecraft/geigercounters/handlers/SignalBarTest.java @@ -49,15 +49,18 @@ BossBar shown() { return captor.getValue(); } + // Closeness at 50 blocks on the 10..100 log scale + static final double AT_50 = 1.0 - Math.log10(5); + @Test void barIsShownOnceThenUpdatedInPlace() { bar.update(player, new Location(world, 0, 64, 50), 50); BossBar shown = shown(); - assertEquals(0.5f, shown.progress(), 1e-6); + assertEquals(AT_50, shown.progress(), 1e-6); assertEquals(BossBar.Color.PURPLE, shown.color()); assertEquals(BossBar.Overlay.NOTCHED_10, shown.overlay()); bar.update(player, new Location(world, 0, 64, -50), 50); - assertEquals(0.25f, shown.progress(), 1e-6); + assertEquals(AT_50 / 2, shown.progress(), 1e-6); verify(player, times(1)).showBossBar(any(BossBar.class)); } @@ -71,7 +74,7 @@ void pinpointRetitlesTheBarAndShowsClosenessAlone() { bar.pinpoint(player, 30); assertEquals(0f, shown.progress(), 1e-6); bar.update(player, new Location(world, 0, 64, 50), 50); - assertEquals(0.5f, shown.progress(), 1e-6); + assertEquals(AT_50, shown.progress(), 1e-6); assertEquals("Arcane signal", PlainTextComponentSerializer.plainText().serialize(shown.name())); verify(player, times(1)).showBossBar(any(BossBar.class)); } @@ -127,9 +130,15 @@ void retainHidesBarsOfPlayersNoLongerHuntingAndForgetsOfflineOnes() { void signalFallsWithDistanceAndPeaksWhenFacingTheSource() { Location from = player.getLocation(); assertEquals(1.0, bar.signal(from, new Location(world, 0, 64, 1), 0), 1e-9); + // Full from pinpoint range in, empty from bar range out + assertEquals(1.0, bar.signal(from, new Location(world, 0, 64, 10), 10), 1e-9); assertEquals(0.0, bar.signal(from, new Location(world, 0, 64, 150), 150), 1e-9); when(config.getBarFacingWeight()).thenReturn(0.0); - assertEquals(0.5, bar.signal(from, new Location(world, 0, 64, -50), 50), 1e-9); + assertEquals(AT_50, bar.signal(from, new Location(world, 0, 64, -50), 50), 1e-9); + // Every halving of the distance fills the same amount + Location behind = new Location(world, 0, 64, -1); + double at20 = bar.signal(from, behind, 20), at40 = bar.signal(from, behind, 40); + assertEquals(at20 - at40, at40 - bar.signal(from, behind, 80), 1e-9); } @Test diff --git a/src/test/java/net/tfminecraft/geigercounters/managers/GeigerManagerTest.java b/src/test/java/net/tfminecraft/geigercounters/managers/GeigerManagerTest.java index d6ecc0a..703f1d7 100644 --- a/src/test/java/net/tfminecraft/geigercounters/managers/GeigerManagerTest.java +++ b/src/test/java/net/tfminecraft/geigercounters/managers/GeigerManagerTest.java @@ -153,18 +153,19 @@ void sourceTooFarOrInAnotherWorldIsPlacedAgain() throws Exception { } @Test - void bothHandsReadHorizontalDistanceAndKeepTheirBars() throws Exception { - Location at = new Location(world, 0, -64, 0); + void bothHandsReadParticlesFlatAndTheBarAndClicksIn3D() throws Exception { + // 20 blocks across, 99 down: 101 in 3D + Location at = new Location(world, 15, 51, 20); when(source.getSource(hunter)).thenReturn(at); Player main = player(world, true, true), off = player(world, false, true); check(main, off); - verify(particles).showParticleEffect(main, 5); - verify(particles).showParticleEffect(off, 5); - verify(clicks).updateRate(off, 5); - verify(bar).update(main, at, 5); + verify(particles).showParticleEffect(main, 20); + verify(particles).showParticleEffect(off, 20); + verify(clicks).updateRate(off, 101); + verify(bar).update(main, at, 101); verify(bar).retain(Set.of(main.getUniqueId(), off.getUniqueId())); verify(source, never()).placeNear(any(), any()); - // Over 200 blocks of depth away: no close reading and no chest + // Too deep for the close reading or the chest verify(clicks, never()).updatePinpoint(any(), anyDouble()); verify(chest, never()).show(any(), any()); verify(chest).retain(Set.of()); @@ -176,8 +177,10 @@ void withinRevealDistanceTheChestIsShownStillOnTheFarReading() throws Exception when(source.getSource(hunter)).thenReturn(at); Player p = player(world, true, false); check(p); - verify(clicks).updateRate(p, 0); - verify(bar).update(p, at, 0); + // Straight above but 20 blocks up: depth keeps the bar short of full + verify(particles).showParticleEffect(p, 0); + verify(clicks).updateRate(p, 20); + verify(bar).update(p, at, 20); verify(chest).show(p, at); verify(chest).retain(Set.of(p.getUniqueId())); } @@ -251,7 +254,7 @@ void globalModeReadsTheSharedSourceWithoutRerollingIt() throws Exception { check(p); verify(source).placeAnywhere(SourceHandler.SHARED); // Far beyond reroll-distance the shared source still reads - Location far = new Location(world, 2003, 64, 4); + Location far = new Location(world, 2003, 150, 4); when(source.getSource(SourceHandler.SHARED)).thenReturn(far); check(p); verify(clicks).updateRate(p, 2000); diff --git a/src/test/java/net/tfminecraft/geigercounters/utils/ClosenessTest.java b/src/test/java/net/tfminecraft/geigercounters/utils/ClosenessTest.java new file mode 100644 index 0000000..9d119d1 --- /dev/null +++ b/src/test/java/net/tfminecraft/geigercounters/utils/ClosenessTest.java @@ -0,0 +1,29 @@ +package net.tfminecraft.geigercounters.utils; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class ClosenessTest { + + @Test + void fullAtNearEmptyAtFarAndClampedOutside() { + assertEquals(1.0, Closeness.logarithmic(0, 10, 1000)); + assertEquals(1.0, Closeness.logarithmic(10, 10, 1000)); + assertEquals(0.0, Closeness.logarithmic(1000, 10, 1000)); + assertEquals(0.0, Closeness.logarithmic(5000, 10, 1000)); + } + + @Test + void eachTenfoldStepFillsTheSameAmount() { + assertEquals(0.5, Closeness.logarithmic(100, 10, 1000), 1e-9); + double half = Closeness.logarithmic(500, 10, 1000) - Closeness.logarithmic(1000, 10, 1000); + assertEquals(half, Closeness.logarithmic(50, 10, 1000) - Closeness.logarithmic(100, 10, 1000), 1e-9); + } + + @Test + void aFarEdgeInsideTheNearEdgeIsAStep() { + assertEquals(1.0, Closeness.logarithmic(20, 20, 10)); + assertEquals(0.0, Closeness.logarithmic(21, 20, 10)); + } +} From 15365d32659505a778f1fa74b91d8b4caf2a6d39 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:37:03 +0200 Subject: [PATCH 2/3] Drop the & from &#RRGGBB colours in messages messages.yml documents &#RRGGBB, but only the bare #RRGGBB was turned into a colour, so titles such as the close-reading bar showed a stray &. Co-Authored-By: Claude Opus 5.5 --- .../net/tfminecraft/geigercounters/utils/Utils.java | 13 ++++++++----- .../geigercounters/LifecycleUtilitiesTest.java | 4 ++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/main/java/net/tfminecraft/geigercounters/utils/Utils.java b/src/main/java/net/tfminecraft/geigercounters/utils/Utils.java index a2f4cc8..bdd7e9d 100644 --- a/src/main/java/net/tfminecraft/geigercounters/utils/Utils.java +++ b/src/main/java/net/tfminecraft/geigercounters/utils/Utils.java @@ -7,19 +7,22 @@ public class Utils { + private static final Pattern HEX_PATTERN = Pattern.compile("&?(#[a-fA-F0-9]{6})"); + // Durations like "12h", "90m", "1d", "30s" - or a bare number, read as seconds private static final Pattern DURATION_PATTERN = Pattern.compile("(?i)^[ \t]*([0-9]+(?:[.][0-9]+)?)[ \t]*([smhd]?)[ \t]*$"); // Keep the existing legacy text representation, formatting, and exact-string comparisons. + // Hex colours may be written #RRGGBB or &#RRGGBB; the & goes with the colour. @SuppressWarnings("deprecation") public static String colorize(String msg) { - Matcher match = Pattern.compile("#[a-fA-F0-9]{6}").matcher(msg); + Matcher match = HEX_PATTERN.matcher(msg); + StringBuilder colored = new StringBuilder(); while (match.find()) { - String color = msg.substring(match.start(), match.end()); - msg = msg.replace(color, String.valueOf(ChatColor.of(color))); - match = Pattern.compile("#[a-fA-F0-9]{6}").matcher(msg); + match.appendReplacement(colored, Matcher.quoteReplacement(String.valueOf(ChatColor.of(match.group(1))))); } - return ChatColor.translateAlternateColorCodes('&', msg); + match.appendTail(colored); + return ChatColor.translateAlternateColorCodes('&', colored.toString()); } // ==================================== diff --git a/src/test/java/net/tfminecraft/geigercounters/LifecycleUtilitiesTest.java b/src/test/java/net/tfminecraft/geigercounters/LifecycleUtilitiesTest.java index 34d594c..9e38d36 100644 --- a/src/test/java/net/tfminecraft/geigercounters/LifecycleUtilitiesTest.java +++ b/src/test/java/net/tfminecraft/geigercounters/LifecycleUtilitiesTest.java @@ -194,6 +194,10 @@ void colorsAndCollectionEventPreservePublicContract() { assertEquals("§aGreen", Utils.colorize("&aGreen")); assertEquals( "§x§f§f§0§0§a§aPink §x§f§f§0§0§a§aAgain", Utils.colorize("#ff00aaPink #ff00aaAgain")); + // The documented &#RRGGBB form leaves no stray & + assertEquals( + "§x§a§a§0§0§f§fArcane §x§a§a§0§0§f§fSignal §7- dig", + Utils.colorize("#aa00ffArcane &#aa00ffSignal &7- dig")); Player player = mock(Player.class); Location location = new Location(mock(World.class), 1, 2, 3); var event = new GeigerSourceCollectEvent(player, location); From dc891ea45df87f304f5daaf49387ce46d82b3ad4 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:50:06 +0200 Subject: [PATCH 3/3] Describe the switch to the close reading in the config comments Co-Authored-By: Claude Opus 5.5 --- src/main/resources/config.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 3b8341e..a611e8f 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -106,8 +106,8 @@ detection: # A boss bar shown while holding a detector. It fills as the player gets # closer and peaks when they face their source. Distance is measured in 3D # on a log scale: each halving of the distance fills the same amount, and the -# bar is full at pinpoint-range. Within pinpoint-range it shows closeness to -# the cache alone. +# bar nears full just outside pinpoint-range. There it switches to the close +# reading, which starts empty and fills up on the cache itself. # ==================================== bar: enabled: true @@ -141,9 +141,10 @@ sound: pitch: 1.7 pitch-variance: 0.15 - # Clicks per second at max-detection-distance and at pinpoint-range. - # Like the bar, the rate climbs on a log scale of the 3D distance. - # 20 is the ceiling - one click per tick + # Clicks per second at max-detection-distance and just outside + # pinpoint-range. Like the bar, the rate climbs on a log scale of the 3D + # distance; at pinpoint-range it drops back to min-rate and climbs again + # to max-rate on the cache. 20 is the ceiling - one click per tick min-rate: 0.5 max-rate: 18.0