Skip to content

Commit b81b6a1

Browse files
committed
Rate limit dropped label visibility
1 parent 5879de2 commit b81b6a1

4 files changed

Lines changed: 244 additions & 31 deletions

File tree

common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java

Lines changed: 129 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,13 @@ public final class DroppedItemDisplay extends VisualizerRunnableDisplay implemen
7676
public static final EntryKey KEY = new EntryKey("item");
7777
private static final int DEFAULT_VIEW_DISTANCE = 64;
7878
private static final int VIEW_DISTANCE_HYSTERESIS = 16;
79+
private static final int DEFAULT_VISIBILITY_BUCKET_SIZE = 128;
80+
private static final int DEFAULT_VISIBILITY_REFILL = 32;
7981

8082
private final Map<UUID, Item> trackedItems = new HashMap<>();
8183
private final Map<UUID, TextDisplay> labels = new HashMap<>();
8284
private final Set<UUID> eligibleViewers = new HashSet<>();
85+
private final Map<UUID, VisibilityState> visibilityStates = new HashMap<>();
8386

8487
private String regularFormatting;
8588
private String singularFormatting;
@@ -93,6 +96,8 @@ public final class DroppedItemDisplay extends VisualizerRunnableDisplay implemen
9396
private int ticksUntilUpdate;
9497
private int despawnTicks = 6000;
9598
private int viewDistance = DEFAULT_VIEW_DISTANCE;
99+
private int visibilityBucketSize = DEFAULT_VISIBILITY_BUCKET_SIZE;
100+
private int visibilityRefill = DEFAULT_VISIBILITY_REFILL;
96101
private boolean stripColorBlacklist;
97102
private DroppedItemBlacklist blacklist = DroppedItemBlacklist.compile(List.of(), DroppedItemDisplay::warn);
98103

@@ -118,6 +123,16 @@ public void onReload(InteractionVisualizerReloadEvent event) {
118123
viewDistance = configuredViewDistance > 0
119124
? Math.max(8, Math.min(512, configuredViewDistance))
120125
: DEFAULT_VIEW_DISTANCE;
126+
int configuredBucketSize = InteractionVisualizer.plugin.getConfiguration()
127+
.getInt("Entities.Item.Options.VisibilityRateLimit.BucketSize");
128+
visibilityBucketSize = configuredBucketSize > 0
129+
? configuredBucketSize
130+
: DEFAULT_VISIBILITY_BUCKET_SIZE;
131+
int configuredRefill = InteractionVisualizer.plugin.getConfiguration()
132+
.getInt("Entities.Item.Options.VisibilityRateLimit.RestorePerTick");
133+
visibilityRefill = configuredRefill > 0
134+
? configuredRefill
135+
: DEFAULT_VISIBILITY_REFILL;
121136
int configuredDespawnTicks = InteractionVisualizer.plugin.getConfiguration().getInt("Entities.Item.Options.DespawnTicks");
122137
despawnTicks = configuredDespawnTicks > 0 ? configuredDespawnTicks : 6000;
123138
stripColorBlacklist = InteractionVisualizer.plugin.getConfiguration()
@@ -158,6 +173,7 @@ public ScheduledTask run() {
158173
return new ScheduledRunnable() {
159174
@Override
160175
public void run() {
176+
drainVisibilityQueues();
161177
if (--ticksUntilUpdate <= 0) {
162178
ticksUntilUpdate = updateRate;
163179
tickAll();
@@ -201,7 +217,10 @@ public void onEntityRemove(EntityRemoveEvent event) {
201217
if (label != null) {
202218
// EntityRemoveEvent is monitoring-only. Defer entity mutation
203219
// until Paper has finished removing the item's passengers.
204-
Scheduler.runTask(InteractionVisualizer.plugin, () -> removeLabel(label));
220+
Scheduler.runTask(InteractionVisualizer.plugin, () -> {
221+
forgetLabelVisibility(itemId, label);
222+
removeLabel(label);
223+
});
205224
}
206225
}
207226
}
@@ -247,6 +266,7 @@ private void tickAll() {
247266
for (TrackedItem tracked : validItems) {
248267
update(tracked, itemIndex, viewerIndex);
249268
}
269+
reconcileLabelVisibility(viewers, validItems);
250270
}
251271

252272
private void update(TrackedItem tracked, DroppedItemSpatialIndex itemIndex,
@@ -281,12 +301,10 @@ private void update(TrackedItem tracked, DroppedItemSpatialIndex itemIndex,
281301
}
282302

283303
Component text = format(stack, ticksLeft);
284-
boolean created = false;
285304
if (label == null || !label.isValid() || !label.getWorld().equals(item.getWorld())) {
286305
removeLabel(item.getUniqueId());
287306
label = spawnLabel(item);
288307
labels.put(item.getUniqueId(), label);
289-
created = true;
290308
}
291309
if (!text.equals(label.text())) {
292310
label.text(text);
@@ -316,11 +334,6 @@ private void update(TrackedItem tracked, DroppedItemSpatialIndex itemIndex,
316334
}
317335
label.teleport(labelLocation(item));
318336
}
319-
if (created) {
320-
// Mount and configure the final render height before revealing the
321-
// label so the first tracking bundle cannot flash at item height.
322-
showToEligibleViewers(label);
323-
}
324337
}
325338

326339
private TextDisplay spawnLabel(Item item) {
@@ -381,36 +394,100 @@ private Collection<Player> reconcileEligibleViewers() {
381394
for (UUID uuid : new HashSet<>(eligibleViewers)) {
382395
if (!desired.containsKey(uuid)) {
383396
Player player = Bukkit.getPlayer(uuid);
384-
if (player != null) {
385-
for (TextDisplay label : labels.values()) {
386-
if (label.isValid()) {
387-
player.hideEntity(InteractionVisualizer.plugin, label);
388-
}
389-
}
390-
}
397+
removeVisibilityState(uuid, player);
391398
eligibleViewers.remove(uuid);
392399
}
393400
}
394401
for (Map.Entry<UUID, Player> entry : desired.entrySet()) {
395-
if (eligibleViewers.add(entry.getKey())) {
396-
Player player = entry.getValue();
397-
for (TextDisplay label : labels.values()) {
398-
if (label.isValid()) {
399-
player.showEntity(InteractionVisualizer.plugin, label);
402+
eligibleViewers.add(entry.getKey());
403+
}
404+
return desired.values();
405+
}
406+
407+
private void reconcileLabelVisibility(Collection<Player> viewers, List<TrackedItem> validItems) {
408+
for (Player player : viewers) {
409+
UUID playerId = player.getUniqueId();
410+
VisibilityState state = visibilityStates.computeIfAbsent(playerId,
411+
ignored -> new VisibilityState(visibilityBucketSize));
412+
Set<UUID> desired = new HashSet<>();
413+
Location playerLocation = player.getLocation();
414+
int trackingDistance = InteractionVisualizer.playerTrackingRange
415+
.getOrDefault(player.getWorld(), DEFAULT_VIEW_DISTANCE);
416+
double range = Math.min(viewDistance, trackingDistance);
417+
double rangeSquared = range * range;
418+
419+
for (TrackedItem tracked : validItems) {
420+
TextDisplay label = labels.get(tracked.itemId());
421+
if (label == null || !label.isValid() || !tracked.item().getWorld().equals(player.getWorld())) {
422+
continue;
423+
}
424+
Location location = tracked.location();
425+
double deltaX = location.getX() - playerLocation.getX();
426+
double deltaY = location.getY() - playerLocation.getY();
427+
double deltaZ = location.getZ() - playerLocation.getZ();
428+
if (deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ <= rangeSquared) {
429+
desired.add(tracked.itemId());
430+
}
431+
}
432+
433+
for (UUID itemId : new HashSet<>(state.shown)) {
434+
if (!desired.contains(itemId)) {
435+
TextDisplay label = labels.get(itemId);
436+
if (label != null && label.isValid()) {
437+
player.hideEntity(InteractionVisualizer.plugin, label);
400438
}
439+
state.shown.remove(itemId);
440+
}
441+
}
442+
for (UUID itemId : state.desired) {
443+
if (!desired.contains(itemId)) {
444+
state.pending.cancel(itemId);
445+
}
446+
}
447+
state.desired = desired;
448+
for (UUID itemId : desired) {
449+
if (!state.shown.contains(itemId)) {
450+
state.pending.request(itemId);
451+
}
452+
}
453+
}
454+
}
455+
456+
private void drainVisibilityQueues() {
457+
for (Map.Entry<UUID, VisibilityState> entry : visibilityStates.entrySet()) {
458+
Player player = Bukkit.getPlayer(entry.getKey());
459+
VisibilityState state = entry.getValue();
460+
if (player == null || !player.isOnline() || !eligibleViewers.contains(entry.getKey())) {
461+
continue;
462+
}
463+
for (UUID itemId : state.pending.drain(visibilityBucketSize, visibilityRefill, id -> {
464+
TextDisplay label = labels.get(id);
465+
return state.desired.contains(id) && !state.shown.contains(id)
466+
&& label != null && label.isValid();
467+
})) {
468+
TextDisplay label = labels.get(itemId);
469+
if (label != null && label.isValid()) {
470+
player.showEntity(InteractionVisualizer.plugin, label);
471+
state.shown.add(itemId);
401472
}
402473
}
403474
}
404-
return desired.values();
405475
}
406476

407-
private void showToEligibleViewers(TextDisplay label) {
408-
for (UUID uuid : eligibleViewers) {
409-
Player player = Bukkit.getPlayer(uuid);
410-
if (player != null) {
411-
player.showEntity(InteractionVisualizer.plugin, label);
477+
private void removeVisibilityState(UUID playerId, Player player) {
478+
VisibilityState state = visibilityStates.remove(playerId);
479+
if (state == null) {
480+
return;
481+
}
482+
if (player != null) {
483+
for (UUID itemId : state.shown) {
484+
TextDisplay label = labels.get(itemId);
485+
if (label != null && label.isValid()) {
486+
player.hideEntity(InteractionVisualizer.plugin, label);
487+
}
412488
}
413489
}
490+
state.pending.clear();
414491
}
415492

416493
private Component format(ItemStack stack, int ticksLeft) {
@@ -460,17 +537,27 @@ private void remove(UUID itemId) {
460537
}
461538

462539
private void removeLabel(UUID itemId) {
463-
removeLabel(labels.remove(itemId));
540+
TextDisplay label = labels.remove(itemId);
541+
forgetLabelVisibility(itemId, label);
542+
removeLabel(label);
464543
}
465544

466-
private void removeLabel(TextDisplay label) {
467-
if (label != null && label.isValid()) {
468-
for (UUID uuid : eligibleViewers) {
469-
Player player = Bukkit.getPlayer(uuid);
545+
private void forgetLabelVisibility(UUID itemId, TextDisplay label) {
546+
for (Map.Entry<UUID, VisibilityState> entry : visibilityStates.entrySet()) {
547+
VisibilityState state = entry.getValue();
548+
state.desired.remove(itemId);
549+
state.pending.cancel(itemId);
550+
if (state.shown.remove(itemId) && label != null && label.isValid()) {
551+
Player player = Bukkit.getPlayer(entry.getKey());
470552
if (player != null) {
471553
player.hideEntity(InteractionVisualizer.plugin, label);
472554
}
473555
}
556+
}
557+
}
558+
559+
private void removeLabel(TextDisplay label) {
560+
if (label != null && label.isValid()) {
474561
label.remove();
475562
}
476563
}
@@ -485,4 +572,15 @@ private static NamespacedKey ownerKey() {
485572

486573
private record TrackedItem(UUID itemId, Item item, Location location) {
487574
}
575+
576+
private static final class VisibilityState {
577+
578+
private Set<UUID> desired = new HashSet<>();
579+
private final Set<UUID> shown = new HashSet<>();
580+
private final VisibilityTokenBucket<UUID> pending;
581+
582+
private VisibilityState(int initialTokens) {
583+
this.pending = new VisibilityTokenBucket<>(initialTokens);
584+
}
585+
}
488586
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* This file is part of InteractionVisualizer.
3+
*
4+
* Copyright (C) 2026. Contributors
5+
*
6+
* This program is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU General Public License as published by
8+
* the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*/
11+
12+
package com.loohp.interactionvisualizer.entities;
13+
14+
import java.util.ArrayDeque;
15+
import java.util.ArrayList;
16+
import java.util.Deque;
17+
import java.util.HashSet;
18+
import java.util.List;
19+
import java.util.Set;
20+
import java.util.function.Predicate;
21+
22+
/** Deduplicated token bucket used to smooth client visibility bursts. */
23+
final class VisibilityTokenBucket<T> {
24+
25+
private final Deque<T> pending = new ArrayDeque<>();
26+
private final Set<T> queued = new HashSet<>();
27+
private int tokens;
28+
29+
VisibilityTokenBucket(int initialTokens) {
30+
this.tokens = Math.max(0, initialTokens);
31+
}
32+
33+
void request(T value) {
34+
if (queued.add(value)) {
35+
pending.addLast(value);
36+
}
37+
}
38+
39+
void cancel(T value) {
40+
queued.remove(value);
41+
}
42+
43+
List<T> drain(int capacity, int refill, Predicate<T> stillWanted) {
44+
tokens = Math.min(Math.max(1, capacity), tokens + Math.max(0, refill));
45+
if (tokens == 0 || pending.isEmpty()) {
46+
return List.of();
47+
}
48+
49+
List<T> ready = new ArrayList<>(Math.min(tokens, pending.size()));
50+
while (tokens > 0 && !pending.isEmpty()) {
51+
T value = pending.removeFirst();
52+
if (!queued.remove(value) || !stillWanted.test(value)) {
53+
continue;
54+
}
55+
ready.add(value);
56+
tokens--;
57+
}
58+
return ready;
59+
}
60+
61+
void clear() {
62+
pending.clear();
63+
queued.clear();
64+
}
65+
}

common/src/main/resources/config.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,10 @@ Entities:
284284
#Maximum distance in blocks at which a dropped-item label is kept active
285285
#Labels outside every eligible player's range are not spawned server-side
286286
ViewDistance: 64
287+
#Smooth label visibility bursts when entering areas with many dropped items
288+
VisibilityRateLimit:
289+
BucketSize: 128
290+
RestorePerTick: 32
287291
#Paper does not expose its internal per-item despawn rate. This value
288292
#drives the public-API timer (6000 ticks = 5 minutes).
289293
DespawnTicks: 6000
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* This file is part of InteractionVisualizer.
3+
*
4+
* Copyright (C) 2026. Contributors
5+
*
6+
* This program is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU General Public License as published by
8+
* the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*/
11+
12+
package com.loohp.interactionvisualizer.entities;
13+
14+
import org.junit.jupiter.api.Test;
15+
16+
import java.util.List;
17+
18+
import static org.junit.jupiter.api.Assertions.assertEquals;
19+
20+
class VisibilityTokenBucketTest {
21+
22+
@Test
23+
void rateLimitsAndRefillsVisibilityChanges() {
24+
VisibilityTokenBucket<Integer> bucket = new VisibilityTokenBucket<>(2);
25+
bucket.request(1);
26+
bucket.request(2);
27+
bucket.request(3);
28+
bucket.request(4);
29+
30+
assertEquals(List.of(1, 2), bucket.drain(2, 0, ignored -> true));
31+
assertEquals(List.of(3), bucket.drain(2, 1, ignored -> true));
32+
assertEquals(List.of(4), bucket.drain(2, 1, ignored -> true));
33+
}
34+
35+
@Test
36+
void deduplicatesAndDropsCancelledOrStaleRequests() {
37+
VisibilityTokenBucket<Integer> bucket = new VisibilityTokenBucket<>(4);
38+
bucket.request(1);
39+
bucket.request(1);
40+
bucket.request(2);
41+
bucket.request(3);
42+
bucket.cancel(2);
43+
44+
assertEquals(List.of(1), bucket.drain(4, 0, value -> value != 3));
45+
}
46+
}

0 commit comments

Comments
 (0)