From 52bd9725072a42c85b72d3bed7785cca4262f32c Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:12:10 +0200 Subject: [PATCH 1/3] fix: judge orb clicks where high-ping players saw the orb Orb hits were tested against the orb's position when the swing reached the server. A player with 200 ms ping sees the orb and clicks about one round trip behind it, so fast orbs had moved past the 0.5 hit radius and the click missed. Keep the last 20 ticks of each orb's path and test the click against the frames matching the player's ping (the tick either side), up to max_rewind_ticks (10 = 500 ms). Low-ping players are judged at the current position as before, and the hit radius and orb speeds are unchanged. Applies to both the weapon attunement orbs and the meditation orbs; the meditation start orb still follows the aim as now. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tfminecraft/magic/gear/orb/GearOrb.java | 10 +++ .../magic/gear/orb/GearOrbService.java | 36 ++++++----- .../tfminecraft/magic/gear/orb/OrbCache.java | 2 + .../magic/loader/ConfigLoader.java | 1 + .../tfminecraft/magic/loader/GearLoader.java | 1 + .../magic/meditation/MeditationCache.java | 2 + .../magic/meditation/MeditationOrb.java | 16 +++++ .../magic/meditation/MeditationService.java | 38 +++++++----- .../net/tfminecraft/magic/util/OrbTrail.java | 62 +++++++++++++++++++ src/main/resources/config.yml | 3 + src/main/resources/gear/orbs.yml | 3 + 11 files changed, 143 insertions(+), 31 deletions(-) create mode 100644 src/main/java/net/tfminecraft/magic/util/OrbTrail.java diff --git a/src/main/java/net/tfminecraft/magic/gear/orb/GearOrb.java b/src/main/java/net/tfminecraft/magic/gear/orb/GearOrb.java index 1219422..868f464 100644 --- a/src/main/java/net/tfminecraft/magic/gear/orb/GearOrb.java +++ b/src/main/java/net/tfminecraft/magic/gear/orb/GearOrb.java @@ -2,6 +2,8 @@ import org.bukkit.Location; +import net.tfminecraft.magic.util.OrbTrail; + /** * One virtual orb circling a crafting station. Particle only, no entity, so it is never * hit by anything but the plugin's own hitscan. @@ -10,6 +12,7 @@ public final class GearOrb { private final Location anchor; private Location location; + private final OrbTrail trail = new OrbTrail(); private final boolean good; private double angle; private final double radius; @@ -54,6 +57,13 @@ public Location getLocation() { public void setLocation(Location location) { this.location = location.clone(); + trail.push(this.location); + } + + /** Where the orb was {@code ticksAgo} ticks back, for ping compensated hits. */ + public Location getLocation(int ticksAgo) { + Location past = trail.ticksAgo(ticksAgo); + return past != null ? past : location; } public boolean isGood() { diff --git a/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java b/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java index 3dce276..c24c9c7 100644 --- a/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java +++ b/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java @@ -32,6 +32,7 @@ import net.tfminecraft.magic.gear.WeaponLore; import net.tfminecraft.magic.gear.WeaponRequirement; import net.tfminecraft.magic.gear.WeaponRift; +import net.tfminecraft.magic.util.OrbTrail; /** * Drives the orb runs. One session per station, so two players cannot attune the same @@ -251,24 +252,29 @@ private static GearOrb hitscan(Player player, GearOrbSession session) { double range = OrbCache.clickRange; double radiusSq = OrbCache.hitRadius * OrbCache.hitRadius; + // Judge the click against where the orb was when this player saw it. + int[] rewind = OrbTrail.rewindTicks(player, OrbCache.maxRewindTicks); + GearOrb best = null; double bestT = range + 1; for (GearOrb orb : session.livingOrbs()) { - Location loc = orb.getLocation(); - if (loc.getWorld() == null || !loc.getWorld().equals(world)) { - continue; - } - Vector oc = loc.toVector().subtract(origin); - double t = oc.dot(dir); - if (t < 0 || t > range) { - continue; - } - if (oc.lengthSquared() - t * t > radiusSq) { - continue; - } - if (t < bestT) { - bestT = t; - best = orb; + for (int ticksAgo : rewind) { + Location loc = orb.getLocation(ticksAgo); + if (loc.getWorld() == null || !loc.getWorld().equals(world)) { + continue; + } + Vector oc = loc.toVector().subtract(origin); + double t = oc.dot(dir); + if (t < 0 || t > range) { + continue; + } + if (oc.lengthSquared() - t * t > radiusSq) { + continue; + } + if (t < bestT) { + bestT = t; + best = orb; + } } } if (best == null) { diff --git a/src/main/java/net/tfminecraft/magic/gear/orb/OrbCache.java b/src/main/java/net/tfminecraft/magic/gear/orb/OrbCache.java index 7211f73..4650a0f 100644 --- a/src/main/java/net/tfminecraft/magic/gear/orb/OrbCache.java +++ b/src/main/java/net/tfminecraft/magic/gear/orb/OrbCache.java @@ -14,6 +14,8 @@ public final class OrbCache { public static double hitRadius = 0.5; public static double clickRange = 12.0; + /** Most ticks a click is rewound for the player's ping (10 = 500 ms). */ + public static int maxRewindTicks = 10; public static double orbitRadius = 2.4; public static double orbitHeight = 1.1; diff --git a/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java b/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java index 1fa788a..4209b70 100644 --- a/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java @@ -248,6 +248,7 @@ private static void loadMeditation(ConfigurationSection section) { MeditationCache.startOrbRange = section.getDouble("start_orb_range", MeditationCache.startOrbRange); MeditationCache.orbHitRadius = section.getDouble("orb_hit_radius", MeditationCache.orbHitRadius); MeditationCache.orbClickRange = section.getDouble("orb_click_range", MeditationCache.orbClickRange); + MeditationCache.maxRewindTicks = Math.max(0, section.getInt("max_rewind_ticks", MeditationCache.maxRewindTicks)); MeditationCache.mentalCostPerHit = section.getInt("mental_cost_per_hit", MeditationCache.mentalCostPerHit); MeditationCache.surgeLockSeconds = section.getInt("surge_lock_seconds", MeditationCache.surgeLockSeconds); MeditationCache.maxLiveOrbs = section.getInt("max_live_orbs", MeditationCache.maxLiveOrbs); diff --git a/src/main/java/net/tfminecraft/magic/loader/GearLoader.java b/src/main/java/net/tfminecraft/magic/loader/GearLoader.java index 247bb7c..4d77e25 100644 --- a/src/main/java/net/tfminecraft/magic/loader/GearLoader.java +++ b/src/main/java/net/tfminecraft/magic/loader/GearLoader.java @@ -165,6 +165,7 @@ private static boolean loadOrbs(File file) { } OrbCache.hitRadius = Math.max(0.1, config.getDouble("hit_radius", OrbCache.hitRadius)); OrbCache.clickRange = Math.max(1.0, config.getDouble("click_range", OrbCache.clickRange)); + OrbCache.maxRewindTicks = Math.max(0, config.getInt("max_rewind_ticks", OrbCache.maxRewindTicks)); OrbCache.orbitRadius = Math.max(0.5, config.getDouble("orbit_radius", OrbCache.orbitRadius)); OrbCache.orbitHeight = config.getDouble("orbit_height", OrbCache.orbitHeight); OrbCache.orbitBob = Math.max(0.0, config.getDouble("orbit_bob", OrbCache.orbitBob)); diff --git a/src/main/java/net/tfminecraft/magic/meditation/MeditationCache.java b/src/main/java/net/tfminecraft/magic/meditation/MeditationCache.java index f571281..b8c7789 100644 --- a/src/main/java/net/tfminecraft/magic/meditation/MeditationCache.java +++ b/src/main/java/net/tfminecraft/magic/meditation/MeditationCache.java @@ -9,6 +9,8 @@ public final class MeditationCache { public static double startOrbRange = 2.5; public static double orbHitRadius = 0.5; public static double orbClickRange = 12.0; + /** Most ticks a click is rewound for the player's ping (10 = 500 ms). */ + public static int maxRewindTicks = 10; public static int mentalCostPerHit = 1; public static int surgeLockSeconds = 10; public static int maxLiveOrbs = 8; diff --git a/src/main/java/net/tfminecraft/magic/meditation/MeditationOrb.java b/src/main/java/net/tfminecraft/magic/meditation/MeditationOrb.java index c59b1cf..6e0a4ce 100644 --- a/src/main/java/net/tfminecraft/magic/meditation/MeditationOrb.java +++ b/src/main/java/net/tfminecraft/magic/meditation/MeditationOrb.java @@ -4,9 +4,12 @@ import org.bukkit.Location; +import net.tfminecraft.magic.util.OrbTrail; + public final class MeditationOrb { private Location location; + private final OrbTrail trail = new OrbTrail(); private final Location spawnLocation; private Location returnFrom; private final UUID sourceId; @@ -64,6 +67,19 @@ public Location getLocation() { public void setLocation(Location location) { this.location = location.clone(); + trail.push(this.location); + } + + /** + * Where the orb was {@code ticksAgo} ticks back, for ping compensated hits. The + * starter follows the player's aim, so it is always judged where it is now. + */ + public Location getLocation(int ticksAgo) { + if (starter) { + return location; + } + Location past = trail.ticksAgo(ticksAgo); + return past != null ? past : location; } public Location getSpawnLocation() { diff --git a/src/main/java/net/tfminecraft/magic/meditation/MeditationService.java b/src/main/java/net/tfminecraft/magic/meditation/MeditationService.java index 0a54d31..ef9b726 100644 --- a/src/main/java/net/tfminecraft/magic/meditation/MeditationService.java +++ b/src/main/java/net/tfminecraft/magic/meditation/MeditationService.java @@ -29,6 +29,7 @@ import net.tfminecraft.magic.profile.MagicProfileService; import net.tfminecraft.magic.session.ResonanceSession; import net.tfminecraft.magic.session.ResonanceSessionManager; +import net.tfminecraft.magic.util.OrbTrail; import net.tfminecraft.rpcharacters.RPCharacters; import net.tfminecraft.rpcharacters.focus.FocusService; @@ -250,25 +251,30 @@ private MeditationOrb hitscan(Player player, MeditationSession session) { double radiusSq = radius * radius; Vector origin = eye.toVector(); + // Judge the click against where the orb was when this player saw it. + int[] rewind = OrbTrail.rewindTicks(player, MeditationCache.maxRewindTicks); + MeditationOrb best = null; double bestT = range + 1; for (MeditationOrb orb : session.livingOrbs()) { - Location loc = orb.getLocation(); - if (loc.getWorld() == null || !loc.getWorld().equals(world)) { - continue; - } - Vector oc = loc.toVector().subtract(origin); - double t = oc.dot(dir); - if (t < 0 || t > range) { - continue; - } - double distSq = oc.lengthSquared() - t * t; - if (distSq > radiusSq) { - continue; - } - if (t < bestT) { - bestT = t; - best = orb; + for (int ticksAgo : rewind) { + Location loc = orb.getLocation(ticksAgo); + if (loc.getWorld() == null || !loc.getWorld().equals(world)) { + continue; + } + Vector oc = loc.toVector().subtract(origin); + double t = oc.dot(dir); + if (t < 0 || t > range) { + continue; + } + double distSq = oc.lengthSquared() - t * t; + if (distSq > radiusSq) { + continue; + } + if (t < bestT) { + bestT = t; + best = orb; + } } } if (best == null) { diff --git a/src/main/java/net/tfminecraft/magic/util/OrbTrail.java b/src/main/java/net/tfminecraft/magic/util/OrbTrail.java new file mode 100644 index 0000000..7add574 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/util/OrbTrail.java @@ -0,0 +1,62 @@ +package net.tfminecraft.magic.util; + +import org.bukkit.Location; +import org.bukkit.entity.Player; + +/** + * Recent positions of a moving orb, one per tick, so a click can be judged against where + * the player actually saw it. + * + *

A player sees an orb about half their ping late and their click reaches us another + * half ping later, so by the time the swing lands the orb has moved on by roughly one + * round trip. Rewinding by the player's own ping keeps the game the same for everyone + * instead of widening the hit radius for all. + */ +public final class OrbTrail { + + /** Ticks kept per orb. Also the hard ceiling on any configured rewind. */ + public static final int CAPACITY = 20; + + private final Location[] positions = new Location[CAPACITY]; + private int head = -1; + private int size; + + /** Records this tick's position. Call once per tick, after the orb has moved. */ + public void push(Location location) { + head = (head + 1) % CAPACITY; + positions[head] = location.clone(); + if (size < CAPACITY) { + size++; + } + } + + /** + * Position {@code ticksAgo} ticks back, clamped to the oldest one kept, or null when + * nothing has been recorded yet. + */ + public Location ticksAgo(int ticksAgo) { + if (size == 0) { + return null; + } + int back = Math.max(0, Math.min(ticksAgo, size - 1)); + return positions[Math.floorMod(head - back, CAPACITY)]; + } + + /** + * Ticks to rewind for this player's ping, as the two whole ticks either side of it so + * a ping between ticks is judged against both frames the player could have seen. + * Low ping players get {@code [0, 0]} or {@code [0, 1]}, so their game is unchanged. + */ + public static int[] rewindTicks(Player player, int maxTicks) { + int ping = player == null ? 0 : Math.max(0, player.getPing()); + return rewindTicks(ping, maxTicks); + } + + public static int[] rewindTicks(int pingMs, int maxTicks) { + int cap = Math.max(0, Math.min(maxTicks, CAPACITY - 1)); + double ticks = Math.min(cap, Math.max(0, pingMs) / 50.0); + int low = (int) Math.floor(ticks); + int high = Math.min(cap, (int) Math.ceil(ticks)); + return new int[] {low, high}; + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index aa8ada1..bb07f4c 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -59,6 +59,9 @@ meditation: start_orb_range: 2.5 orb_hit_radius: 0.5 orb_click_range: 12 + # A click is judged against where the orb was when the player saw it, rewound by + # their ping up to this many ticks (10 = 500 ms). 0 turns it off. + max_rewind_ticks: 10 mental_cost_per_hit: 1 surge_lock_seconds: 10 max_live_orbs: 8 diff --git a/src/main/resources/gear/orbs.yml b/src/main/resources/gear/orbs.yml index 9ff8272..891bb27 100644 --- a/src/main/resources/gear/orbs.yml +++ b/src/main/resources/gear/orbs.yml @@ -11,6 +11,9 @@ # Hitscan. Radius is the cylinder around the aim ray an orb must fall inside. hit_radius: 0.5 click_range: 12 +# A click is judged against where the orb was when the player saw it, rewound by +# their ping up to this many ticks (10 = 500 ms). 0 turns it off. +max_rewind_ticks: 10 # Orbit around the station block centre. orbit_radius: 2.4 From 72e8c74b7335ad66a867875b66c6502104a5b4a9 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:24:25 +0200 Subject: [PATCH 2/3] fix: skip orb frames from before the orb spawned A late click could rewind past an orb's first tick and was then judged at its oldest recorded position, a frame the player never saw. Return no frame instead and skip it. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/main/java/net/tfminecraft/magic/gear/orb/GearOrb.java | 8 +++++--- .../net/tfminecraft/magic/gear/orb/GearOrbService.java | 2 +- .../net/tfminecraft/magic/meditation/MeditationOrb.java | 8 ++++---- .../tfminecraft/magic/meditation/MeditationService.java | 2 +- src/main/java/net/tfminecraft/magic/util/OrbTrail.java | 8 ++++---- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/gear/orb/GearOrb.java b/src/main/java/net/tfminecraft/magic/gear/orb/GearOrb.java index 868f464..973c2da 100644 --- a/src/main/java/net/tfminecraft/magic/gear/orb/GearOrb.java +++ b/src/main/java/net/tfminecraft/magic/gear/orb/GearOrb.java @@ -60,10 +60,12 @@ public void setLocation(Location location) { trail.push(this.location); } - /** Where the orb was {@code ticksAgo} ticks back, for ping compensated hits. */ + /** + * Where the orb was {@code ticksAgo} ticks back, for ping compensated hits, or null + * if it had not spawned yet. + */ public Location getLocation(int ticksAgo) { - Location past = trail.ticksAgo(ticksAgo); - return past != null ? past : location; + return trail.ticksAgo(ticksAgo); } public boolean isGood() { diff --git a/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java b/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java index c24c9c7..e5ae289 100644 --- a/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java +++ b/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java @@ -260,7 +260,7 @@ private static GearOrb hitscan(Player player, GearOrbSession session) { for (GearOrb orb : session.livingOrbs()) { for (int ticksAgo : rewind) { Location loc = orb.getLocation(ticksAgo); - if (loc.getWorld() == null || !loc.getWorld().equals(world)) { + if (loc == null || loc.getWorld() == null || !loc.getWorld().equals(world)) { continue; } Vector oc = loc.toVector().subtract(origin); diff --git a/src/main/java/net/tfminecraft/magic/meditation/MeditationOrb.java b/src/main/java/net/tfminecraft/magic/meditation/MeditationOrb.java index 6e0a4ce..7f15878 100644 --- a/src/main/java/net/tfminecraft/magic/meditation/MeditationOrb.java +++ b/src/main/java/net/tfminecraft/magic/meditation/MeditationOrb.java @@ -71,15 +71,15 @@ public void setLocation(Location location) { } /** - * Where the orb was {@code ticksAgo} ticks back, for ping compensated hits. The - * starter follows the player's aim, so it is always judged where it is now. + * Where the orb was {@code ticksAgo} ticks back, for ping compensated hits, or null + * if it had not spawned yet. The starter follows the player's aim, so it is always + * judged where it is now. */ public Location getLocation(int ticksAgo) { if (starter) { return location; } - Location past = trail.ticksAgo(ticksAgo); - return past != null ? past : location; + return trail.ticksAgo(ticksAgo); } public Location getSpawnLocation() { diff --git a/src/main/java/net/tfminecraft/magic/meditation/MeditationService.java b/src/main/java/net/tfminecraft/magic/meditation/MeditationService.java index ef9b726..57896b1 100644 --- a/src/main/java/net/tfminecraft/magic/meditation/MeditationService.java +++ b/src/main/java/net/tfminecraft/magic/meditation/MeditationService.java @@ -259,7 +259,7 @@ private MeditationOrb hitscan(Player player, MeditationSession session) { for (MeditationOrb orb : session.livingOrbs()) { for (int ticksAgo : rewind) { Location loc = orb.getLocation(ticksAgo); - if (loc.getWorld() == null || !loc.getWorld().equals(world)) { + if (loc == null || loc.getWorld() == null || !loc.getWorld().equals(world)) { continue; } Vector oc = loc.toVector().subtract(origin); diff --git a/src/main/java/net/tfminecraft/magic/util/OrbTrail.java b/src/main/java/net/tfminecraft/magic/util/OrbTrail.java index 7add574..6ddff74 100644 --- a/src/main/java/net/tfminecraft/magic/util/OrbTrail.java +++ b/src/main/java/net/tfminecraft/magic/util/OrbTrail.java @@ -31,14 +31,14 @@ public void push(Location location) { } /** - * Position {@code ticksAgo} ticks back, clamped to the oldest one kept, or null when - * nothing has been recorded yet. + * Position {@code ticksAgo} ticks back, or null when the orb did not exist yet, so a + * late click is never matched to a frame the player could not have seen. */ public Location ticksAgo(int ticksAgo) { - if (size == 0) { + int back = Math.max(0, ticksAgo); + if (back >= size) { return null; } - int back = Math.max(0, Math.min(ticksAgo, size - 1)); return positions[Math.floorMod(head - back, CAPACITY)]; } From 6146fbdd8039d4fe6257c0b55e2cb9f7747e89df Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:40:03 +0200 Subject: [PATCH 3/3] fix: check both frames when ping is a whole number of ticks At exactly 50 or 100 ms the rewind asked for one frame only, so a click was judged against a single frame and a freshly spawned orb could be skipped. Also check the newer neighbouring frame. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/main/java/net/tfminecraft/magic/util/OrbTrail.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/util/OrbTrail.java b/src/main/java/net/tfminecraft/magic/util/OrbTrail.java index 6ddff74..031dc56 100644 --- a/src/main/java/net/tfminecraft/magic/util/OrbTrail.java +++ b/src/main/java/net/tfminecraft/magic/util/OrbTrail.java @@ -44,8 +44,9 @@ public Location ticksAgo(int ticksAgo) { /** * Ticks to rewind for this player's ping, as the two whole ticks either side of it so - * a ping between ticks is judged against both frames the player could have seen. - * Low ping players get {@code [0, 0]} or {@code [0, 1]}, so their game is unchanged. + * a ping between ticks is judged against both frames the player could have seen. A + * ping of exactly whole ticks also gets the newer frame. Zero ping gets {@code [0, 0]} + * and low ping {@code [0, 1]}, so their game is unchanged. */ public static int[] rewindTicks(Player player, int maxTicks) { int ping = player == null ? 0 : Math.max(0, player.getPing()); @@ -57,6 +58,9 @@ public static int[] rewindTicks(int pingMs, int maxTicks) { double ticks = Math.min(cap, Math.max(0, pingMs) / 50.0); int low = (int) Math.floor(ticks); int high = Math.min(cap, (int) Math.ceil(ticks)); + if (low == high) { + low = Math.max(0, low - 1); + } return new int[] {low, high}; } }