From 38c9f249e1dc116471f172d82f772a4beee7dc34 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 19:38:49 +0000 Subject: [PATCH] Throttle client-link requests per player --- .../main/java/net/scopenet/core/ClientLink.java | 8 +++++++- .../src/test/java/net/scopenet/core/LinkTest.java | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/integrations/common/src/main/java/net/scopenet/core/ClientLink.java b/integrations/common/src/main/java/net/scopenet/core/ClientLink.java index 2510fa4..4d66adb 100644 --- a/integrations/common/src/main/java/net/scopenet/core/ClientLink.java +++ b/integrations/common/src/main/java/net/scopenet/core/ClientLink.java @@ -22,6 +22,9 @@ public final class ClientLink { private final Map lastState = new ConcurrentHashMap<>(); private final Map lastClaimsKey = new ConcurrentHashMap<>(); private final Set clients = ConcurrentHashMap.newKeySet(); + private final Map lastRequest = new ConcurrentHashMap<>(); + /** One request per player at most this often; a modded client can't turn the link into a panel flood. */ + static final long MIN_REQUEST_GAP_MS = 400; public ClientLink(Env env, PlayerCache cache, String serverName, BiConsumer out) { this.env = env; this.cache = cache; this.serverName = serverName; this.out = out; @@ -30,7 +33,7 @@ public final class ClientLink { public boolean hasClient(UUID uuid) { return clients.contains(uuid); } public void disconnected(UUID uuid) { - clients.remove(uuid); lastState.remove(uuid); lastClaimsKey.remove(uuid); cache.forget(uuid); + clients.remove(uuid); lastState.remove(uuid); lastClaimsKey.remove(uuid); lastRequest.remove(uuid); cache.forget(uuid); } private void send(CorePlayer p, JsonObject message) { out.accept(p, Wire.encode(message.toString())); } @@ -42,6 +45,9 @@ public final class ClientLink { catch (RuntimeException e) { return; } String type = PlayerCache.str(msg, "t", ""); if (!env.features.clientLink()) return; + long now = env.clock.getAsLong(); + Long previous = lastRequest.put(p.uuid(), now); + if (previous != null && now - previous < MIN_REQUEST_GAP_MS && !type.equals("hello")) return; switch (type) { case "hello" -> hello(p); case "refresh" -> { if (clients.contains(p.uuid())) { lastState.remove(p.uuid()); cache.refresh(p, info -> pushState(p)); } } diff --git a/integrations/common/src/test/java/net/scopenet/core/LinkTest.java b/integrations/common/src/test/java/net/scopenet/core/LinkTest.java index 11f1beb..dc8801c 100644 --- a/integrations/common/src/test/java/net/scopenet/core/LinkTest.java +++ b/integrations/common/src/test/java/net/scopenet/core/LinkTest.java @@ -102,12 +102,27 @@ class LinkTest { assertEquals(99.0, last("state").get("balance").getAsDouble()); } + @Test void requestsAreThrottledPerPlayer() { + link.receive(alex, msg("{\"t\":\"hello\"}")); + kit.panel.on("economy/market", "[]"); + kit.now.addAndGet(1000); + link.receive(alex, msg("{\"t\":\"market\"}")); + for (int i = 0; i < 20; i++) link.receive(alex, msg("{\"t\":\"market\"}")); + assertEquals(1, kit.panel.calls.stream().filter(c -> c.equals("economy/market")).count(), "a burst is one panel call"); + kit.now.addAndGet(ClientLink.MIN_REQUEST_GAP_MS + 1); + link.receive(alex, msg("{\"t\":\"market\"}")); + assertEquals(2, kit.panel.calls.stream().filter(c -> c.equals("economy/market")).count()); + } + @Test void marketAndShopRequests() { link.receive(alex, msg("{\"t\":\"hello\"}")); + kit.now.addAndGet(1000); kit.panel.on("economy/market", "[{\"id\":7,\"seller_name\":\"Mia\",\"item_id\":\"DIAMOND\",\"item_name\":\"Diamond\",\"amount\":3,\"price\":99.5}]"); link.receive(alex, msg("{\"t\":\"market\"}")); + kit.now.addAndGet(1000); assertEquals(7, last("market").getAsJsonArray("listings").get(0).getAsJsonObject().get("id").getAsInt()); alex.held = new Item("DIAMOND", "Diamond", 4, ""); + kit.now.addAndGet(1000); link.receive(alex, msg("{\"t\":\"shop\"}")); JsonObject shop = last("shop"); assertTrue(shop.getAsJsonArray("items").size() > 10);