Fix pivot tap-spam speed exploit (#72)

_finish_pivot_tap() granted a full PIVOT_MIN_STEP rotation on every
button/key release with no rate limit, so tapping fast enough rotated
the formation far beyond PIVOT_SPEED's smooth held-rate cap - well
above what holding the button down can ever produce. Each of those
artificially-frequent re-seats (Fleet.apply_formation()) forced every
ship into a fresh max-speed catch-up dash toward its new slot with no
time to settle between dashes, and since outer-wedge ships swing
further per pivot than inner ones, spamming made them visibly diverge
in speed while each ship's own bounded max_speed made the fleet read
as constantly pinned at its speed ceiling.

Added a per-direction cooldown (the time a genuine hold needs to reach
PIVOT_MIN_STEP on its own) so tap-spam can't out-pace holding the
button. Deliberately NOT shared between CW/CCW: a quick correction tap
the other way right after an overshoot is normal play, and
test_keyboard_input.gd already covers Q and E each independently
producing their own minimum step back-to-back - only repeatedly
re-tapping the SAME direction needed capping.
This commit is contained in:
Loïc Wisniewski 2026-08-17 19:47:50 +02:00
commit d68aac6444
2 changed files with 46 additions and 3 deletions

View file

@ -42,8 +42,28 @@ var pinch_start_zoom: float = 1.0
# release, so tapping still produces a clear, visible nudge.
const PIVOT_SPEED: float = 1.6
const PIVOT_MIN_STEP: float = PI / 10.0
# #72: the tap top-up below used to fire in full on EVERY release, with no
# regard for how recently the last one landed - so tapping fast enough
# granted rotation far above PIVOT_SPEED's smooth rate, well beyond what
# holding the button can ever produce. Each of those artificially-frequent
# re-seats (Fleet.apply_formation()) forces every ship into a fresh
# max-speed catch-up dash toward its new slot with no time to settle
# between them, and since outer-wedge ships swing further per pivot than
# inner ones, spamming made them visibly diverge in speed ("desync") while
# each individual ship's own bounded max_speed made the fleet as a whole
# read as constantly at its speed ceiling ("build up speed"). This cooldown
# is exactly the time a genuine hold needs to reach PIVOT_MIN_STEP on its
# own, so tap-spam can't out-pace holding the button down.
const PIVOT_TAP_COOLDOWN: float = PIVOT_MIN_STEP / PIVOT_SPEED
var pivot_hold_amount: float = 0.0
# Tracked PER DIRECTION, not shared - a single quick correction (tap the
# other way right after an overshoot) is normal play, not the spam this
# cooldown targets, and Q/E each independently producing their own minimum
# step back-to-back is an existing, tested guarantee (test_keyboard_input.gd).
# Only repeatedly re-tapping the SAME direction needs to be capped.
var pivot_cw_tap_cooldown_left: float = 0.0
var pivot_ccw_tap_cooldown_left: float = 0.0
# Touch index currently holding each pivot button, or -1. These buttons are
# driven straight from raw touch events rather than through _gui_input,
# because Godot's Control GUI treats touch as a single emulated pointer and
@ -186,9 +206,16 @@ func _update_pivot(delta: float) -> void:
func _finish_pivot_tap(direction: float) -> void:
# Fired on release. A press held long enough has already turned far
# enough on its own; a brief tap gets topped up to the minimum step.
if pivot_hold_amount < PIVOT_MIN_STEP:
# enough on its own; a brief tap gets topped up to the minimum step -
# but only once THAT DIRECTION's own cooldown has cleared, so spamming
# one side can't grant rotation faster than holding the button would.
var cooldown_left: float = pivot_cw_tap_cooldown_left if direction > 0.0 else pivot_ccw_tap_cooldown_left
if pivot_hold_amount < PIVOT_MIN_STEP and cooldown_left <= 0.0:
_pivot((PIVOT_MIN_STEP - pivot_hold_amount) * direction)
if direction > 0.0:
pivot_cw_tap_cooldown_left = PIVOT_TAP_COOLDOWN
else:
pivot_ccw_tap_cooldown_left = PIVOT_TAP_COOLDOWN
pivot_hold_amount = 0.0
@ -211,6 +238,8 @@ func _on_joystick_direction(direction: Vector2) -> void:
func _process(delta: float) -> void:
pivot_cw_tap_cooldown_left = maxf(pivot_cw_tap_cooldown_left - delta, 0.0)
pivot_ccw_tap_cooldown_left = maxf(pivot_ccw_tap_cooldown_left - delta, 0.0)
_update_pivot(delta)
# Keep the heading fresh while a control is held. Fleet advances its own

View file

@ -5,7 +5,10 @@ extends SceneTree
# Covers the fleet-pivot buttons:
# 1. HOLDING one turns the formation continuously, not once per press
# 2. a brief tap still produces a clear minimum step
# 3. pivoting works while the fleet is under way, i.e. the new layout
# 3. immediately repeating a tap grants nothing extra until the cooldown
# clears (#72) - spamming taps must never rotate faster than holding
# the button would
# 4. pivoting works while the fleet is under way, i.e. the new layout
# survives the joystick's per-frame steering updates instead of being
# overwritten by the old offsets
@ -76,6 +79,17 @@ func _process(delta: float) -> bool:
quit(1)
return true
# --- #72: spamming taps grants nothing extra during the cooldown ---
var before_spam: float = _angle()
for i in range(5):
controller._finish_pivot_tap(1.0)
var spammed: float = absf(angle_difference(before_spam, _angle()))
print("5 taps back-to-back -> turned %.4f rad (expected ~0, cooldown not yet cleared)" % spammed)
if spammed > 0.001:
print("TEST_PIVOT_CONTROLS: FAIL (tap-spam granted rotation during the cooldown)")
quit(1)
return true
# --- pivot WHILE the joystick is held -------------------------
# Two fingers at once. This is routed through the same raw-touch
# path the real device uses, because Godot's Control GUI treats